insika 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +295 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +351 -0
- data/docs/AGENTS.md +494 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +100 -0
- data/docs/DEPLOY.md +334 -0
- data/docs/EMBEDDING.md +194 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +231 -0
- data/docs/OBSERVABILITY.md +365 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +362 -0
- data/docs/SKILLS.md +98 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +188 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +150 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/chat_builder.rb +254 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/coercion.rb +50 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +41 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +71 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +50 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +85 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +19 -0
- data/lib/insika/context/priority.rb +29 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +108 -0
- data/lib/insika/context/providers/skill.rb +20 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +294 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +379 -0
- data/lib/insika/dsl/server_boot.rb +97 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +307 -0
- data/lib/insika/edge_limiter.rb +130 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +246 -0
- data/lib/insika/errors.rb +145 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +114 -0
- data/lib/insika/executor.rb +1680 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +114 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +103 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +121 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +137 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +127 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +87 -0
- data/lib/insika/safety/moderator.rb +86 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/admin_auth.rb +29 -0
- data/lib/insika/server/app.rb +850 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +110 -0
- data/lib/insika/server/responses.rb +155 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +113 -0
- data/lib/insika/skill_store.rb +79 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1571 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +69 -0
- data/lib/insika/studio/forms.rb +340 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +62 -0
- data/lib/insika/studio/views/settings.erb +173 -0
- data/lib/insika/studio/views/skills.erb +86 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +220 -0
- data/lib/insika/tools/load_skill.rb +41 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +158 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +198 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +203 -4
- metadata +395 -8
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Server
|
|
7
|
+
# Turns the components into a service.
|
|
8
|
+
# MANDATORY order, no parallelism: plugins → stores → recovery → (app
|
|
9
|
+
# for the listen). "Never accepts a request before recovery" is guaranteed BY
|
|
10
|
+
# CONSTRUCTION: the listen (Falcon) only runs after `#call` returns the app, and
|
|
11
|
+
# `#call` only returns after `Recovery.run` finishes.
|
|
12
|
+
class Boot
|
|
13
|
+
# wiring: object with the named steps (load_plugins/build_stores/
|
|
14
|
+
# recovery/app) — the config/wiring.rb. logger: simple IO (default $stdout;
|
|
15
|
+
# nil silences). app: overrides the wiring's `app` step — for a serving arm
|
|
16
|
+
# (config.ru) that assembles its own Rack app around the wiring; the
|
|
17
|
+
# "recovery before the listen" guarantee is unchanged (#call still only
|
|
18
|
+
# returns after recovery).
|
|
19
|
+
def initialize(wiring, logger: $stdout, app: nil)
|
|
20
|
+
@wiring = wiring
|
|
21
|
+
@logger = logger
|
|
22
|
+
@app = app
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# -> Rack app ready for the `run`. A store failure at boot (corrupted
|
|
26
|
+
# file → StoreError) PROPAGATES and aborts the process (coming up
|
|
27
|
+
# without durability is worse than not coming up); an unrecoverable task does NOT
|
|
28
|
+
# bring down the boot (Recovery already marks it :failed).
|
|
29
|
+
def call
|
|
30
|
+
@wiring.load_plugins
|
|
31
|
+
@wiring.build_stores
|
|
32
|
+
warn_if_ephemeral
|
|
33
|
+
summary = run_recovery
|
|
34
|
+
log("boot: recovery complete — #{summary[:resumed].size} resumed, " \
|
|
35
|
+
"#{summary[:failed].size} failed")
|
|
36
|
+
@app || @wiring.app
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Recovery dispatches resume_task, which creates task fibers — needs a
|
|
42
|
+
# current reactor. At config.ru load (Falcon) there is NO reactor: the Sync { }
|
|
43
|
+
# creates one and, by structured concurrency, only returns when the resume
|
|
44
|
+
# fibers FINISH (recovery + turns completed before the listen — slower
|
|
45
|
+
# boot, semantically safe). Under an already-current reactor (tests
|
|
46
|
+
# inside Async), it runs directly: returns after the resume DISPATCH, with
|
|
47
|
+
# the turns still in flight — also correct: "recovery before the listen" =
|
|
48
|
+
# dispatch before the listen, not turn completion.
|
|
49
|
+
def run_recovery
|
|
50
|
+
return do_recovery if Async::Task.current?
|
|
51
|
+
|
|
52
|
+
Sync { do_recovery }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Task recovery THEN delegation recovery (RFC-0010 Fase 2): the delegation
|
|
56
|
+
# sweep re-delivers completed-but-undelivered async delegations, and depends
|
|
57
|
+
# on the task sweep having re-dispatched any in-flight children first. Both
|
|
58
|
+
# create task fibers, so both must run inside the reactor scope of run_recovery.
|
|
59
|
+
#
|
|
60
|
+
# The TASK sweep is additionally gated per boot generation (RFC-0016 E2):
|
|
61
|
+
# its "orphaned :running" test cannot see a sibling worker's live fiber, so
|
|
62
|
+
# only the worker that claims the generation sweeps — the others would steal
|
|
63
|
+
# in-flight turns. The delegation and channel sweeps stay ungated: each of
|
|
64
|
+
# their records carries its own transactional claim (at-most-once holds
|
|
65
|
+
# however many workers sweep). Duck-typed: a wiring without the claim (test
|
|
66
|
+
# doubles, single-process arms) sweeps unconditionally.
|
|
67
|
+
def do_recovery
|
|
68
|
+
summary =
|
|
69
|
+
if skip_task_sweep?
|
|
70
|
+
log("boot: task sweep skipped — another worker claimed this boot generation")
|
|
71
|
+
{ resumed: [], failed: [] }
|
|
72
|
+
else
|
|
73
|
+
@wiring.recovery.run
|
|
74
|
+
end
|
|
75
|
+
recover_delegations
|
|
76
|
+
recover_channel_deliveries
|
|
77
|
+
summary
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def skip_task_sweep?
|
|
81
|
+
@wiring.respond_to?(:claim_recovery_sweep) && !@wiring.claim_recovery_sweep
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Duck-typed (like durable?): a wiring without async delegation just omits it.
|
|
85
|
+
def recover_delegations
|
|
86
|
+
return unless @wiring.respond_to?(:recover_delegations)
|
|
87
|
+
|
|
88
|
+
result = @wiring.recover_delegations
|
|
89
|
+
log("boot: delegations re-delivered — #{Array(result && result[:delivered]).size}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# RFC-0011 §6.5: replies a previous process committed but never handed to the
|
|
93
|
+
# channel. Runs AFTER the task recovery for the same reason the delegation
|
|
94
|
+
# sweep does — a resumed turn writes its own outbox record at its terminal, and
|
|
95
|
+
# sweeping first would miss it.
|
|
96
|
+
def recover_channel_deliveries
|
|
97
|
+
return unless @wiring.respond_to?(:recover_channel_deliveries)
|
|
98
|
+
|
|
99
|
+
result = @wiring.recover_channel_deliveries
|
|
100
|
+
log("boot: channel replies re-dispatched — #{Array(result && result[:dispatched]).size}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Durability: without a durable backend, nothing is resumed after a
|
|
104
|
+
# restart — warns loudly at boot so we don't come up "without a net" by mistake. The
|
|
105
|
+
# test wiring (double) may not expose `durable?`; in that case, silence.
|
|
106
|
+
def warn_if_ephemeral
|
|
107
|
+
return unless @wiring.respond_to?(:durable?)
|
|
108
|
+
return if @wiring.durable?
|
|
109
|
+
|
|
110
|
+
log("boot: WARNING — EPHEMERAL backend (no INSIKA_DB): recovery will " \
|
|
111
|
+
"not resume anything after a restart (doc 02 §6).")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def log(message)
|
|
115
|
+
@logger&.puts(message)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RFC-0017 A3 — the /v1 transport as a VALUE the host app mounts, instead of a
|
|
4
|
+
# server the engine starts.
|
|
5
|
+
#
|
|
6
|
+
# mount Insika::Server.rack_app(INSIKA, token: ENV.fetch("INSIKA_TOKEN")), at: "/ai"
|
|
7
|
+
#
|
|
8
|
+
# The assembly used to live inline in DSL::ServerBoot#run, welded to
|
|
9
|
+
# `Async::HTTP::Server.new(...).run` on the next line: a host that already owns a
|
|
10
|
+
# reactor and a router could not reach it. Nothing here is new behavior — the
|
|
11
|
+
# server boot now calls this instead of inlining it, which is what keeps the two
|
|
12
|
+
# from drifting.
|
|
13
|
+
#
|
|
14
|
+
# The Studio is deliberately NOT part of this (the embed contract, item 4): it is
|
|
15
|
+
# a class-level singleton, so it is one per process, and a host that wants the
|
|
16
|
+
# operator UI mounts `Studio::App` itself and accepts that limitation.
|
|
17
|
+
|
|
18
|
+
require_relative "app"
|
|
19
|
+
|
|
20
|
+
module Insika
|
|
21
|
+
module Server
|
|
22
|
+
# `handle` is anything that answers #runtime (a DSL Definition/System) or a
|
|
23
|
+
# DSL::Runtime itself. -> a Rack app (`#call(env)`).
|
|
24
|
+
#
|
|
25
|
+
# It is mount-safe: routing reads `path_info`, so `Rack::URLMap`/Rails' `mount`
|
|
26
|
+
# moving the prefix into SCRIPT_NAME leaves every route intact.
|
|
27
|
+
def self.rack_app(handle, token: nil, **config)
|
|
28
|
+
AppBuilder.new(handle, token: token, **config).app
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Assembles Server::App from a graph. Also answers the two questions the boot
|
|
32
|
+
# banner asks (`workflows?`/`channels?`), so registering the env channels
|
|
33
|
+
# happens exactly once and in one place.
|
|
34
|
+
class AppBuilder
|
|
35
|
+
# Fixed local token: gates /v1 (Bearer) — and, under `serve`, logs into the
|
|
36
|
+
# Studio (cookie). Never a real secret; override with `token:`/ADMIN_TOKEN.
|
|
37
|
+
def initialize(handle, token: nil, **config)
|
|
38
|
+
@rt = handle.respond_to?(:runtime) ? handle.runtime : handle
|
|
39
|
+
@graph = @rt.graph
|
|
40
|
+
@token = token || ENV.fetch("ADMIN_TOKEN", "local-demo")
|
|
41
|
+
@config = config
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :token
|
|
45
|
+
|
|
46
|
+
def app
|
|
47
|
+
@app ||= Insika::Server::App.new(
|
|
48
|
+
command_bus: @graph.bus, event_stream: @graph.event_stream,
|
|
49
|
+
session_store: @graph.session_store, task_store: @graph.task_store,
|
|
50
|
+
pending_action_store: @graph.pending_action_store,
|
|
51
|
+
provisioner: Insika::PackImporter.new(bus: @graph.bus, profiles: @graph.profiles),
|
|
52
|
+
# GET /v1/agents/:id — the read-only capability view a case's `requires`
|
|
53
|
+
# resolves against (RFC-0014 §3.2).
|
|
54
|
+
profiles: @graph.profiles,
|
|
55
|
+
# Item 20 / §5.6: the OSS onboarding surface (start.md + models.json + docs).
|
|
56
|
+
# This is the primary "build my first agent" target — models.json reports the
|
|
57
|
+
# DSL's stores + the agents this process serves (each id IS the `model`).
|
|
58
|
+
onboarding: build_onboarding,
|
|
59
|
+
# Item 22: GET /v1/workflows + POST /v1/workflows/:name, opt-in by
|
|
60
|
+
# injection like every other edge — nil when the system declares none,
|
|
61
|
+
# so the routes simply do not exist (404, parity).
|
|
62
|
+
workflow_registry: (@graph.workflow_registry if workflows?),
|
|
63
|
+
# RFC-0011: the bundled relay, when the env turns it on. Same rule as the
|
|
64
|
+
# OTEL bridge — a feature only `config.ru` can reach is a feature the
|
|
65
|
+
# docs are half-true about.
|
|
66
|
+
channels: (@graph.channel_registry if channels?),
|
|
67
|
+
config: { gateway_token: @token }.merge(@config)
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def workflows? = !@graph.workflow_registry.names.empty?
|
|
72
|
+
|
|
73
|
+
# Registers the env-configured channels once, and reports whether any exist.
|
|
74
|
+
def channels?
|
|
75
|
+
unless defined?(@channels_ready)
|
|
76
|
+
@channels_ready = true
|
|
77
|
+
relay = Insika::Channels::Relay.from_env(
|
|
78
|
+
http: Insika::HttpClient.new,
|
|
79
|
+
allow_http: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_HTTP"]),
|
|
80
|
+
allow_private: Insika::EnvSchema.truthy?(ENV["INSIKA_EGRESS_ALLOW_PRIVATE"])
|
|
81
|
+
)
|
|
82
|
+
@graph.channel_registry.register(relay.id, relay) if relay
|
|
83
|
+
|
|
84
|
+
widget = Insika::Channels::Web.from_env(
|
|
85
|
+
chat_rate_limit: Insika::Channels::Web.limit_resolver(
|
|
86
|
+
profiles: @graph.profiles, settings_store: @rt.component(:settings_store)
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
@graph.channel_registry.register(widget.id, widget) if widget
|
|
90
|
+
end
|
|
91
|
+
!@graph.channel_registry.names.empty?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def build_onboarding
|
|
97
|
+
configs = @rt.packs.map(&:config)
|
|
98
|
+
Insika::Onboarding.standard(
|
|
99
|
+
root: File.expand_path("../../..", __dir__),
|
|
100
|
+
settings_store: @rt.component(:settings_store),
|
|
101
|
+
provider_store: @rt.component(:provider_store),
|
|
102
|
+
# EVERY agent this process serves — each id IS a `model` on
|
|
103
|
+
# /v1/responses, so a coding agent reading models.json sees the whole
|
|
104
|
+
# system, not just the first one.
|
|
105
|
+
agents: -> { configs.map { |c| { id: c[:id], model: c[:model], provider: c[:provider] } } }
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Server
|
|
7
|
+
# OpenAI Responses edge adapter (`/v1/responses`) — the contract that the
|
|
8
|
+
# OpenClaw gateway consumers already speak (see achei-b2b
|
|
9
|
+
# `CoreServices::OpenclawDispatcher`). Phase 6, Step A.
|
|
10
|
+
#
|
|
11
|
+
# PURE module (no state, no framework): (a) translates the OpenAI
|
|
12
|
+
# Responses request → `:send_message` payload; (b) maps each turn Event →
|
|
13
|
+
# OpenAI Responses SSE frame (or nil for events with no counterpart). Follows the
|
|
14
|
+
# constitutional rule: no business logic, no store access here.
|
|
15
|
+
#
|
|
16
|
+
# Request: { model: "openclaw:<agent>", user: "<chat.id>", stream: true,
|
|
17
|
+
# input: "<string with already-composed blocks>" } + header
|
|
18
|
+
# X-Openclaw-Agent (agent fallback). The `input` enters VERBATIM as the
|
|
19
|
+
# turn's message — the blocks (<memoria>/<dados_conhecidos>/directives) already come
|
|
20
|
+
# composed by the consumer (the engine does not interpret them).
|
|
21
|
+
module Responses
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# -> { agent:, user:, message:, origin? } | raise ValidationError.
|
|
25
|
+
#
|
|
26
|
+
# `origin` is the consumer declaring WHO wrote the input it is sending. It
|
|
27
|
+
# matters here more than anywhere: this adapter's `input` is a STRING the
|
|
28
|
+
# consumer already composed out of context blocks plus the customer's text
|
|
29
|
+
# (`<memoria> …`, `<cacau_cep_obrigatorio> …`), so a transcript reader cannot
|
|
30
|
+
# tell the two apart — the first refinement run over real traffic reported 219
|
|
31
|
+
# "the customer repeated themselves" that were the engine reading its own
|
|
32
|
+
# fragment back. A consumer that sends `origin: "engine"` on a composed turn
|
|
33
|
+
# gets that filtered structurally instead of by a regex on the leading tag.
|
|
34
|
+
# Omitted = a customer typed it, which is what every turn meant before.
|
|
35
|
+
def parse_request(body, req)
|
|
36
|
+
agent = body[:model].to_s.sub(/\Aopenclaw:/, "")
|
|
37
|
+
agent = req.get_header("HTTP_X_OPENCLAW_AGENT").to_s if agent.empty?
|
|
38
|
+
raise Insika::ValidationError, "model/agent missing" if agent.strip.empty?
|
|
39
|
+
|
|
40
|
+
user = body[:user].to_s
|
|
41
|
+
raise Insika::ValidationError, "user missing" if user.strip.empty?
|
|
42
|
+
|
|
43
|
+
message = extract_input(body[:input])
|
|
44
|
+
raise Insika::ValidationError, "input empty" if message.strip.empty?
|
|
45
|
+
|
|
46
|
+
out = { agent: agent.strip, user: user, message: message }
|
|
47
|
+
(origin = Insika::MessageOrigin.parse!(body[:origin])) && (out[:origin] = origin)
|
|
48
|
+
out
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# V1: `input` is a STRING (the dispatcher composes the blocks + user text). Tolerates
|
|
52
|
+
# an array of parts (OpenAI multimodal shape) by joining the texts.
|
|
53
|
+
def extract_input(input)
|
|
54
|
+
case input
|
|
55
|
+
when String then input
|
|
56
|
+
when Array
|
|
57
|
+
input.flat_map { |part| part.is_a?(Hash) ? (part[:text] || part["text"]) : part }
|
|
58
|
+
.compact.join("\n")
|
|
59
|
+
else input.to_s
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Turn Event -> OpenAI Responses SSE frame | nil (event with no
|
|
64
|
+
# counterpart: :task_started, :tool_result, :skill_activated, ...).
|
|
65
|
+
# Terminal events emit the final frame + `[DONE]` (close the stream).
|
|
66
|
+
def frame_for(event)
|
|
67
|
+
case event.type
|
|
68
|
+
when :content
|
|
69
|
+
sse("response.output_text.delta",
|
|
70
|
+
{ type: "response.output_text.delta", delta: event.data[:delta].to_s })
|
|
71
|
+
when :tool_call
|
|
72
|
+
sse("response.output_item.added",
|
|
73
|
+
{ type: "response.output_item.added",
|
|
74
|
+
item: { type: "function_call", name: event.data[:name].to_s } })
|
|
75
|
+
when :task_completed
|
|
76
|
+
completed(event) + done
|
|
77
|
+
when :task_failed
|
|
78
|
+
failed(event.data[:message] || "task failed") + done
|
|
79
|
+
when :task_cancelled
|
|
80
|
+
failed("task cancelled") + done
|
|
81
|
+
when :error
|
|
82
|
+
failed(event.data[:message] || "error") + done
|
|
83
|
+
when :thinking
|
|
84
|
+
# The provider's reasoning. Internal unless the AGENT opted in
|
|
85
|
+
# (`edge_stream thinking: true`), which tags the event. Even then it does
|
|
86
|
+
# NOT become answer text: it gets the Responses reasoning frame, so a
|
|
87
|
+
# consumer that only accumulates `output_text` deltas — achei-b2b's
|
|
88
|
+
# dispatcher, which turns them into one WhatsApp message — is unaffected,
|
|
89
|
+
# and one that renders reasoning has something to render.
|
|
90
|
+
if public_delta(event)
|
|
91
|
+
sse("response.reasoning_summary_text.delta",
|
|
92
|
+
{ type: "response.reasoning_summary_text.delta", delta: event.data[:delta].to_s })
|
|
93
|
+
end
|
|
94
|
+
when :intermediate
|
|
95
|
+
# The model's own prose that did not turn out to be the answer — the
|
|
96
|
+
# narration of a message that also called a tool, or the reasoning-in-content
|
|
97
|
+
# a model emits when it has no tool to call. A real store's prompt sent 132
|
|
98
|
+
# deltas of an English monologue this way before TurnOutput held them back.
|
|
99
|
+
#
|
|
100
|
+
# NAMESPACED on purpose when published. There is no `response.*` event for
|
|
101
|
+
# "text the assistant said that is not the answer": in the real protocol that
|
|
102
|
+
# text IS `output_text.delta`, told apart only by an output-item index this
|
|
103
|
+
# adapter does not carry. So a `response.*` type here would be a lie a strict
|
|
104
|
+
# client would believe. `insika.*` is obviously ours and unknown types are
|
|
105
|
+
# ignored — which is the safe failure.
|
|
106
|
+
if public_delta(event)
|
|
107
|
+
sse("insika.intermediate.delta",
|
|
108
|
+
{ type: "insika.intermediate.delta", delta: event.data[:delta].to_s })
|
|
109
|
+
end
|
|
110
|
+
when :guardrail_blocked, :guardrail_flagged
|
|
111
|
+
# RFC-0009: audit events with no OpenAI Responses counterpart. On a BLOCK
|
|
112
|
+
# the safe reply still reaches the consumer through the normal :content
|
|
113
|
+
# deltas + :task_completed path (the turn completes gracefully), so there
|
|
114
|
+
# is nothing extra to translate here — the events live in /v1/events + the
|
|
115
|
+
# Studio + the trace. Explicit (not a fall-through) to keep the closed
|
|
116
|
+
# catalog honest.
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Did the AGENT opt this channel in? The Executor tags the event (`edge_stream`)
|
|
122
|
+
# because this mapper is pure and static — no agent, no stores, no state. An
|
|
123
|
+
# untagged event is internal, which is the default and the safe reading — and
|
|
124
|
+
# "not published" must be nil, like every other unmapped event in the catalog.
|
|
125
|
+
def public_delta(event) = event.data[:public] == true
|
|
126
|
+
|
|
127
|
+
def completed(event)
|
|
128
|
+
response = {}
|
|
129
|
+
if (usage = event.data[:usage])
|
|
130
|
+
# `model` travels alongside usage in the event; in the OpenAI shape it is a sibling of
|
|
131
|
+
# usage (pure tokens in usage).
|
|
132
|
+
model = usage[:model] || usage["model"]
|
|
133
|
+
response[:usage] = usage.reject { |k, _| k.to_s == "model" }
|
|
134
|
+
response[:model] = model if model
|
|
135
|
+
end
|
|
136
|
+
# Opt-in per-turn latency breakdown (INSIKA_TURN_TIMING; item 34). Absent
|
|
137
|
+
# by default — a non-standard sibling used only for TTFB diagnostics.
|
|
138
|
+
(timing = event.data[:timing]) && (response[:timing] = timing)
|
|
139
|
+
sse("response.completed", { type: "response.completed", response: response })
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def failed(message)
|
|
143
|
+
sse("response.failed",
|
|
144
|
+
{ type: "response.failed", response: { error: { message: message.to_s } } })
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# event: + data: (the dispatcher reads both: `event:` and `type` in the JSON).
|
|
148
|
+
def sse(event_name, data)
|
|
149
|
+
"event: #{event_name}\ndata: #{JSON.generate(data)}\n\n"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def done = "data: [DONE]\n\n"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "async"
|
|
6
|
+
require "async/queue"
|
|
7
|
+
|
|
8
|
+
module Insika
|
|
9
|
+
module Server
|
|
10
|
+
# SSE response body (evolves `SSEStream`). `SSEStream`
|
|
11
|
+
# received a producer block (the Runner wrote into it); `SSEBody` DRAINS a
|
|
12
|
+
# `Subscription` from the EventStream: each subscriber has its
|
|
13
|
+
# own queue; `#each` blocks the CONSUMER's fiber until the subscription
|
|
14
|
+
# closes. The wire is EXACTLY `Event#to_h`.
|
|
15
|
+
class SSEBody
|
|
16
|
+
PING = ": ping\n\n" # SSE comment — doesn't pollute the consumer
|
|
17
|
+
|
|
18
|
+
# serialize: maps an Event -> String (SSE frame) OR nil (discarded
|
|
19
|
+
# event, no frame). Default = the canonical wire `data: <Event#to_h>`.
|
|
20
|
+
# The /v1/responses adapter injects a serializer that produces OpenAI
|
|
21
|
+
# Responses events (and skips those with no counterpart).
|
|
22
|
+
DEFAULT_SERIALIZE = ->(event) { "data: #{JSON.generate(event.to_h)}\n\n" }
|
|
23
|
+
|
|
24
|
+
# subscription: any object with #each (yields Events) and #close.
|
|
25
|
+
# heartbeat: seconds of silence before emitting a ping (15s
|
|
26
|
+
# clears 60s ALB/nginx idle timeouts with room to spare).
|
|
27
|
+
def initialize(subscription:, heartbeat: 15, serialize: nil)
|
|
28
|
+
@subscription = subscription
|
|
29
|
+
@heartbeat = heartbeat
|
|
30
|
+
@serialize = serialize || DEFAULT_SERIALIZE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Rack 3 STREAMING BODY (`#call(stream)`), NOT `#each`. Under
|
|
34
|
+
# protocol-rack/protocol-http1 (the stack of Async::HTTP::Server AND Falcon),
|
|
35
|
+
# a body that responds to `#each` is routed to Body::Enumerable, whose
|
|
36
|
+
# `read` runs the `#each` in a PLAIN Enumerator Fiber (not an Async::Task) —
|
|
37
|
+
# there `Async::Task.current` raises "No async task available", the loop died
|
|
38
|
+
# swallowed in the rescue and the body came out EMPTY. By exposing `#call` (and NOT `#each`),
|
|
39
|
+
# the body is routed to Body::Streaming, which schedules the block via
|
|
40
|
+
# Fiber.schedule under the reactor's scheduler — so the subscription drains and the
|
|
41
|
+
# frames actually reach the socket (incrementally).
|
|
42
|
+
#
|
|
43
|
+
# The `stream` (Protocol::HTTP::Body::Stream) responds to #write/#close. Drains the
|
|
44
|
+
# subscription DIRECTLY (no Async::Task.current): when this fiber blocks
|
|
45
|
+
# waiting for the next event, the scheduler runs the writer, which pushes the
|
|
46
|
+
# already-written frame to the socket. Heartbeat via Timeout.timeout (scheduler hook),
|
|
47
|
+
# which works in the scheduled fiber — keeps the connection alive while idle (L4).
|
|
48
|
+
def call(stream)
|
|
49
|
+
drain(stream)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
# Client disconnected: `stream.write` raises when the socket closes.
|
|
52
|
+
# No exception escapes; the turn's task is NEVER cancelled here — the
|
|
53
|
+
# execution belongs to the runtime, not the connection (reconnect at /v1/events).
|
|
54
|
+
nil
|
|
55
|
+
ensure
|
|
56
|
+
@subscription.close
|
|
57
|
+
stream.close
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def drain(stream)
|
|
63
|
+
internal = Async::Queue.new
|
|
64
|
+
closed = Object.new # end-of-subscription sentinel
|
|
65
|
+
|
|
66
|
+
# Child fiber (scheduled on the reactor's scheduler): drains the subscription
|
|
67
|
+
# into an internal queue and, on close, pushes the sentinel. Isolates the
|
|
68
|
+
# subscription's blocking from the heartbeat loop. Ends on its own when
|
|
69
|
+
# `@subscription.close` (in #call's ensure) makes the `each` finish — no
|
|
70
|
+
# need to kill the fiber by hand.
|
|
71
|
+
Fiber.schedule do
|
|
72
|
+
@subscription.each { |event| internal.enqueue(event) }
|
|
73
|
+
ensure
|
|
74
|
+
internal.enqueue(closed)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
loop do
|
|
78
|
+
event =
|
|
79
|
+
begin
|
|
80
|
+
Timeout.timeout(@heartbeat) { internal.dequeue }
|
|
81
|
+
rescue Timeout::Error
|
|
82
|
+
:heartbeat # no event within `heartbeat`s -> ping
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if event.equal?(:heartbeat)
|
|
86
|
+
stream.write(PING)
|
|
87
|
+
elsif event.equal?(closed)
|
|
88
|
+
break
|
|
89
|
+
elsif (frame = @serialize.call(event))
|
|
90
|
+
stream.write(frame)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/queue"
|
|
5
|
+
require "time" # Time#iso8601 for the arrival record on :turn_coalesced
|
|
6
|
+
|
|
7
|
+
module Insika
|
|
8
|
+
# Sessions as Actors: one fiber per session with a FIFO queue
|
|
9
|
+
# of turns, executed ONE AT A TIME. Restores the "one owner at a time"
|
|
10
|
+
# invariant of the transcript that two concurrent `send_message` calls on the
|
|
11
|
+
# same `session_id` would break (read-modify-write on the Session Store). Turns
|
|
12
|
+
# from distinct sessions stay concurrent; one-shot/history (no session_id) do
|
|
13
|
+
# not go through here.
|
|
14
|
+
#
|
|
15
|
+
# Lives in the SUPERVISED scope: the loop is a child of the supervisor, not of
|
|
16
|
+
# the request — it outlives the connection. The turn itself (spawned by the
|
|
17
|
+
# Executor) is also born on the supervisor; the SessionActor only AWAITS it to
|
|
18
|
+
# serialize.
|
|
19
|
+
#
|
|
20
|
+
# RFC-0015: it is also where an inbound message for a BUSY session is routed.
|
|
21
|
+
# That decision belongs here and nowhere else — this is already the object that
|
|
22
|
+
# owns "one turn at a time for this session". Putting it in the HTTP handler
|
|
23
|
+
# would duplicate the invariant; putting it in the Executor would mix turn
|
|
24
|
+
# execution with queue policy.
|
|
25
|
+
class SessionActor
|
|
26
|
+
def initialize(session_id:, executor:, parent: Async::Task.current)
|
|
27
|
+
@session_id = session_id
|
|
28
|
+
@executor = executor
|
|
29
|
+
@queue = Async::Queue.new
|
|
30
|
+
@running = false
|
|
31
|
+
# The turn currently sitting at the door: created and :queued, but not yet
|
|
32
|
+
# released to run. `collect` merges into THIS one. nil whenever there is
|
|
33
|
+
# nothing mergeable — which is the common case and the safe default.
|
|
34
|
+
@pending = nil
|
|
35
|
+
@loop = parent.async { |t| t.annotate("session:#{session_id}"); run_loop }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Enqueues a turn (FIFO). Non-blocking: the handler responds with an
|
|
39
|
+
# immediate {task_id:} even if the turn stays :queued behind another. -> task.id.
|
|
40
|
+
#
|
|
41
|
+
# `policy` (a QueuePolicy) opens the debounce window for this turn; nil or a
|
|
42
|
+
# policy without a window behaves exactly as before — dequeued and run at once.
|
|
43
|
+
def enqueue(task, profile:, resume_from: nil, policy: nil)
|
|
44
|
+
@queue.enqueue([task, profile, resume_from, policy])
|
|
45
|
+
task.id
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# RFC-0015 §5.3 — merge a fragment into the turn waiting at the door.
|
|
49
|
+
# -> the task id it joined, or nil when there is nothing to merge into (no
|
|
50
|
+
# pending turn, the window has closed, or the turn already started). nil is
|
|
51
|
+
# the caller's signal to create a task of its own.
|
|
52
|
+
#
|
|
53
|
+
# Runs on the REQUEST's fiber, not the loop's; both are on the same reactor
|
|
54
|
+
# and neither yields between the check and the write below, so the "is it
|
|
55
|
+
# still mergeable" test and the append cannot interleave.
|
|
56
|
+
def collect(text)
|
|
57
|
+
pending = @pending
|
|
58
|
+
return nil if pending.nil?
|
|
59
|
+
|
|
60
|
+
@executor.task_store.append_message(pending[:task_id], text)
|
|
61
|
+
pending[:count] += 1
|
|
62
|
+
# A merged fragment leaves NO task of its own (see #hold_at_the_door), so this
|
|
63
|
+
# is the only record that it arrived as a separate message. Kept as arrival
|
|
64
|
+
# times — never the text — and shipped on :turn_coalesced, so "the customer
|
|
65
|
+
# says they sent the order number" is answerable without the store carrying an
|
|
66
|
+
# orphan task per fragment.
|
|
67
|
+
pending[:arrivals] << Time.now.utc.iso8601
|
|
68
|
+
pending[:version] += 1 # tells a sleeping debounce window that more arrived
|
|
69
|
+
pending[:task_id]
|
|
70
|
+
rescue ArgumentError
|
|
71
|
+
# The turn left :queued between the read of @pending and the append (it was
|
|
72
|
+
# released while we were deciding). Not an error: the caller falls back to
|
|
73
|
+
# creating its own task, which is exactly `followup`.
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def running? = @running
|
|
78
|
+
def depth = @queue.size
|
|
79
|
+
|
|
80
|
+
# The turn this session is running RIGHT NOW, or nil when idle or still at the
|
|
81
|
+
# door. `steer` needs the Task itself and not just its id: whether a turn can
|
|
82
|
+
# absorb a message at all depends on what kind of turn it is (a workflow has no
|
|
83
|
+
# chat), and reading that off the object avoids a store round-trip on the
|
|
84
|
+
# request's path.
|
|
85
|
+
attr_reader :current_task
|
|
86
|
+
|
|
87
|
+
# Is there a turn at the door that `collect` could still merge into?
|
|
88
|
+
def collecting? = !@pending.nil?
|
|
89
|
+
|
|
90
|
+
# Is the loop still alive? (the Executor revalidates before reusing from the
|
|
91
|
+
# cache — a dead loop would black-hole queued turns).
|
|
92
|
+
def alive? = !!@loop&.running?
|
|
93
|
+
|
|
94
|
+
# Shuts down the loop (server shutdown / tests — the loop blocks forever on
|
|
95
|
+
# dequeue when idle).
|
|
96
|
+
def stop = @loop&.stop
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def run_loop
|
|
101
|
+
loop do
|
|
102
|
+
task, profile, resume_from, policy = @queue.dequeue # blocks when empty
|
|
103
|
+
task = hold_at_the_door(task, policy)
|
|
104
|
+
@running = true
|
|
105
|
+
@current_task = task
|
|
106
|
+
begin
|
|
107
|
+
@executor.run_serial(task, profile: profile, resume_from: resume_from)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
# run_serial already maps turn errors; this rescue is defense: an
|
|
110
|
+
# unexpected error must NEVER bring down the session loop (Async::Stop <
|
|
111
|
+
# Exception is not captured -> #stop ends the loop normally).
|
|
112
|
+
nil
|
|
113
|
+
ensure
|
|
114
|
+
@running = false
|
|
115
|
+
@current_task = nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# RFC-0015 §5.3 — the debounce window. Sleeps on the LOOP's fiber, never on the
|
|
121
|
+
# request's, so the POST is acked immediately and the platform does not retry.
|
|
122
|
+
# Returns the task to run (re-read from the store when fragments merged into it,
|
|
123
|
+
# since the in-memory Task is a frozen snapshot of an older message).
|
|
124
|
+
def hold_at_the_door(task, policy)
|
|
125
|
+
return task unless policy&.debounce?
|
|
126
|
+
|
|
127
|
+
@pending = { task_id: task.id, count: 1, version: 0, arrivals: [Time.now.utc.iso8601] }
|
|
128
|
+
begin
|
|
129
|
+
wait_for_quiet(policy)
|
|
130
|
+
merged = @pending[:count]
|
|
131
|
+
arrivals = @pending[:arrivals]
|
|
132
|
+
ensure
|
|
133
|
+
# The window is closed BEFORE the turn runs, under every exit path: a
|
|
134
|
+
# `collect` that slipped in here would append to a task about to be read.
|
|
135
|
+
@pending = nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
return task if merged == 1
|
|
139
|
+
|
|
140
|
+
@executor.emit_coalesced(task, merged: merged, arrivals: arrivals)
|
|
141
|
+
@executor.task_store.find(task.id) || task
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Sleeps in `debounce_ms` slices, restarting whenever a fragment arrives
|
|
145
|
+
# (`version` moved), until either a slice passes in silence or the total
|
|
146
|
+
# deferral reaches `debounce_max_ms` — the ceiling that stops a customer who
|
|
147
|
+
# keeps typing from postponing their own answer forever.
|
|
148
|
+
def wait_for_quiet(policy)
|
|
149
|
+
quiet = policy.debounce_ms / 1000.0
|
|
150
|
+
deadline = monotonic + (policy.debounce_max_ms / 1000.0)
|
|
151
|
+
|
|
152
|
+
loop do
|
|
153
|
+
mark = @pending[:version]
|
|
154
|
+
Async::Task.current.sleep(quiet)
|
|
155
|
+
break if @pending[:version] == mark # a full slice of silence
|
|
156
|
+
break if monotonic >= deadline
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
161
|
+
end
|
|
162
|
+
end
|