insika 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -3
- data/README.md +1 -1
- data/bin/insika +22 -7
- data/docs/AGENTS.md +129 -5
- data/docs/CHANNELS.md +1 -1
- data/docs/CONTEXT.md +22 -5
- data/docs/DEPLOY.md +30 -10
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +1 -1
- data/docs/LOADTEST.md +3 -2
- data/docs/OBSERVABILITY.md +11 -2
- data/docs/REFINEMENT.md +6 -6
- data/docs/RELEASING.md +7 -7
- data/docs/RUNNING-LOCAL.md +1 -1
- data/docs/SECURITY.md +24 -11
- data/docs/SKILLS.md +189 -3
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/index.md +1 -1
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +89 -22
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/channel_delivery.rb +14 -11
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +3 -3
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +7 -7
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +62 -13
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +5 -3
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/gate_refinement.rb +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +9 -9
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/context/builder.rb +2 -2
- data/lib/insika/context/fragment.rb +27 -3
- data/lib/insika/context/priority.rb +3 -2
- data/lib/insika/context/providers/memory.rb +1 -1
- data/lib/insika/context/providers/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +5 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/doctor.rb +250 -5
- data/lib/insika/dsl/runtime.rb +12 -9
- data/lib/insika/dsl/server_boot.rb +4 -3
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +72 -15
- data/lib/insika/edge_limiter.rb +144 -6
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +13 -10
- data/lib/insika/errors.rb +61 -5
- data/lib/insika/evals/assertions.rb +12 -12
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +3 -3
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/executor.rb +423 -108
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/golden_store.rb +2 -2
- data/lib/insika/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- data/lib/insika/message_origin.rb +2 -2
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/onboarding.rb +2 -2
- data/lib/insika/outbox_store.rb +2 -2
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- data/lib/insika/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/profile_source.rb +12 -6
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +2 -2
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +9 -9
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +2 -2
- data/lib/insika/safety/detectors.rb +5 -5
- data/lib/insika/safety/factory.rb +3 -3
- data/lib/insika/safety/input_guardrail.rb +19 -4
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +2 -2
- data/lib/insika/safety/output_validator.rb +2 -2
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/server/app.rb +223 -51
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +15 -7
- data/lib/insika/server/responses.rb +18 -8
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +3 -3
- data/lib/insika/session_store.rb +1 -1
- data/lib/insika/settings_store.rb +5 -5
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +127 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +1 -1
- data/lib/insika/studio/app.rb +183 -61
- data/lib/insika/studio/assets/dist/application.js +25 -24
- data/lib/insika/studio/forms.rb +13 -18
- data/lib/insika/studio/nav_icons.rb +1 -1
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +2 -2
- data/lib/insika/studio/views/agents.erb +1 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +78 -3
- data/lib/insika/studio/views/settings.erb +7 -12
- data/lib/insika/studio/views/skills.erb +67 -12
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +1 -1
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +27 -27
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +8 -8
- data/lib/insika/tool_envelope.rb +1 -1
- data/lib/insika/tool_manifest.rb +6 -6
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +4 -5
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +17 -13
- data/lib/insika/turn_timing.rb +2 -2
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +77 -26
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +32 -15
- metadata +19 -2
- data/lib/insika/server/admin_auth.rb +0 -29
data/lib/insika/executor.rb
CHANGED
|
@@ -19,7 +19,8 @@ module Insika
|
|
|
19
19
|
event_stream:, workflow_registry: nil, pending_action_store: nil,
|
|
20
20
|
capability_registry: nil, tool_catalog: nil, memory_store: nil,
|
|
21
21
|
tool_trace_store: nil, settings_store: nil, content_filter_factory: nil,
|
|
22
|
-
|
|
22
|
+
delegation_store: nil, channel_delivery: nil, llm: nil,
|
|
23
|
+
context_trace_store: nil, reliability: nil)
|
|
23
24
|
@context_builder = context_builder
|
|
24
25
|
@policy_engine = policy_engine
|
|
25
26
|
@middleware = middleware
|
|
@@ -36,31 +37,38 @@ module Insika
|
|
|
36
37
|
@pending_action_store = pending_action_store # approval gate
|
|
37
38
|
@capability_registry = capability_registry # capability resolution (nil = off)
|
|
38
39
|
@tool_trace_store = tool_trace_store # tool-call trace for Studio debugging (nil = off)
|
|
39
|
-
#
|
|
40
|
+
# per-turn context breakdown (tokens by category + budget) for the
|
|
41
|
+
# Studio session card. nil = off (no record, zero overhead — parity).
|
|
42
|
+
@context_trace_store = context_trace_store
|
|
43
|
+
# Guardrails output filter: ->(state) { OutputFilter | nil }.
|
|
40
44
|
# Injected by the Safety::Factory; nil = off (parity — the stream is untouched).
|
|
41
45
|
# The INPUT guardrail is a Middleware (in the stack, not here); this is the seam
|
|
42
46
|
# for the stream-side redaction the Executor owns.
|
|
43
47
|
@content_filter_factory = content_filter_factory
|
|
44
|
-
#
|
|
48
|
+
# durable record of ASYNC delegations. nil = async
|
|
45
49
|
# delegation OFF (only the synchronous spawn_subagent works — parity). When
|
|
46
50
|
# present, run_subagent(async: true) dispatches + returns immediately and the
|
|
47
51
|
# child's result is delivered to the parent session as a NEW turn on completion.
|
|
48
52
|
@delegation_store = delegation_store
|
|
49
|
-
#
|
|
53
|
+
# outbound delivery for Shape B channels. nil = no channel
|
|
50
54
|
# delivers out of band (parity — every surface today answers on the request's
|
|
51
55
|
# own connection). When present, a turn that CAME IN through a channel writes
|
|
52
56
|
# its answer to the outbox at the terminal and the dispatcher POSTs it.
|
|
53
57
|
@channel_delivery = channel_delivery
|
|
54
|
-
#
|
|
58
|
+
# the chat FACTORY this executor asks — a RubyLLM::Context (an
|
|
55
59
|
# isolated config dup) when the graph owns its credentials, nil = the
|
|
56
60
|
# process-wide RubyLLM constant (the historic single-graph deployment).
|
|
57
61
|
# Duck-typed: Context#chat and RubyLLM.chat take the same keywords.
|
|
58
62
|
@llm = llm
|
|
59
|
-
#
|
|
63
|
+
# stability for the turn's single agent interaction (WS3): retries /
|
|
64
|
+
# fallback / circuit breaker, all DATA on AgentProfile#reliability.
|
|
65
|
+
# nil = the plain single ask (parity).
|
|
66
|
+
@reliability = reliability
|
|
67
|
+
# LLM config v2: resolves the model at turn start (Chat > Agent >
|
|
60
68
|
# platform default) + model_policy + fallback chain. settings_store nil =
|
|
61
69
|
# no platform layer (pre-v2 behavior: the agent's own model is used as-is).
|
|
62
70
|
@model_resolver = ModelResolver.new(settings_store: settings_store)
|
|
63
|
-
#
|
|
71
|
+
# the platform layer of the queue policy (nil = per-agent and
|
|
64
72
|
# defaults only, which is `followup` with no window — today's behavior).
|
|
65
73
|
@settings_store = settings_store
|
|
66
74
|
# RubyLLM glue (stages 5-7): chat assembly delegated to ChatBuilder. Its
|
|
@@ -71,13 +79,15 @@ module Insika
|
|
|
71
79
|
tool_registry: tool_registry, skill_catalog: skill_catalog,
|
|
72
80
|
checkpoint_store: checkpoint_store, event_stream: event_stream, hooks: hooks,
|
|
73
81
|
tool_catalog: tool_catalog, memory_store: memory_store,
|
|
74
|
-
#
|
|
82
|
+
# load_skill is not enveloped, so it records its own trace entry.
|
|
83
|
+
tool_trace_store: tool_trace_store,
|
|
84
|
+
# the ChatBuilder wires the spawn_subagent system tool (gated by
|
|
75
85
|
# profile.subagents) and hands it this Executor as the runner. `self` is not
|
|
76
86
|
# yet fully built here, but the ChatBuilder only STORES it (used per-turn).
|
|
77
87
|
subagent_runner: self
|
|
78
88
|
)
|
|
79
|
-
# Stage-3-tail tool assembly (capability resolution, instantiation,
|
|
80
|
-
# injection, dedup join, ToolEnvelope wrap) — extracted collaborator
|
|
89
|
+
# Stage-3-tail tool assembly (capability resolution, instantiation,
|
|
90
|
+
# injection, dedup join, ToolEnvelope wrap) — extracted collaborator.
|
|
81
91
|
@tool_assembly = ToolAssembly.new(
|
|
82
92
|
tool_registry: tool_registry, capability_registry: capability_registry,
|
|
83
93
|
event_stream: event_stream, checkpoint_store: checkpoint_store,
|
|
@@ -88,7 +98,7 @@ module Insika
|
|
|
88
98
|
@supervised = false # serving mode? — see #turn_parent
|
|
89
99
|
@supervisor = nil # lazy long-lived supervisor (created when serving)
|
|
90
100
|
@session_actors = {} # session_id => SessionActor (FIFO queue)
|
|
91
|
-
@draining = false # shutdown drain
|
|
101
|
+
@draining = false # shutdown drain — see #begin_drain!
|
|
92
102
|
end
|
|
93
103
|
|
|
94
104
|
# Turns on SERVING mode: the composition root's serving arm (serve.rb /
|
|
@@ -102,7 +112,19 @@ module Insika
|
|
|
102
112
|
# concurrency).
|
|
103
113
|
attr_accessor :supervised
|
|
104
114
|
|
|
105
|
-
#
|
|
115
|
+
# the periodic tick (outbox drain + stale recovery sweep), wired
|
|
116
|
+
# by the graph AFTER the bus exists (the tick's recovery half dispatches
|
|
117
|
+
# through it). nil = no tick (parity — recovery stays boot-only). When
|
|
118
|
+
# present and serving, it starts as a child of the turn supervisor (see
|
|
119
|
+
# #turn_parent).
|
|
120
|
+
attr_accessor :tick
|
|
121
|
+
|
|
122
|
+
# The WS6 alert dispatcher: answers budget_warning / breaker_open /
|
|
123
|
+
# delivery_failed with a durable webhook delivery. Started as a child of
|
|
124
|
+
# the turn supervisor in serving mode (like the tick); nil = no alerts.
|
|
125
|
+
attr_accessor :alert_dispatcher
|
|
126
|
+
|
|
127
|
+
# closes the TURN intake for shutdown. Armed by Insika::Shutdown
|
|
106
128
|
# when the process is asked to stop: from here on a new top-level turn is left
|
|
107
129
|
# `:queued` (durable — the next boot's recovery replays it) instead of
|
|
108
130
|
# spawning, while the in-flight turns run to their natural end. One-way by
|
|
@@ -217,7 +239,7 @@ module Insika
|
|
|
217
239
|
# time); without a session_id (one-shot/history) it goes straight to spawn
|
|
218
240
|
# (standalone).
|
|
219
241
|
def spawn_in_session(task, profile:, resume_from: nil)
|
|
220
|
-
#
|
|
242
|
+
# the intake is closed. The task is already durable (queued);
|
|
221
243
|
# answering with its id and spawning NOTHING is what "stops accepting new
|
|
222
244
|
# turns" means — the next boot's recovery replays it. Subagent turns are NOT
|
|
223
245
|
# gated (they spawn directly): a child of an in-flight parent is part of the
|
|
@@ -237,7 +259,7 @@ module Insika
|
|
|
237
259
|
policy: queue_policy(profile, task.session_id))
|
|
238
260
|
end
|
|
239
261
|
|
|
240
|
-
#
|
|
262
|
+
# the `collect` door, asked BEFORE a task is created.
|
|
241
263
|
# -> the task id the fragment joined, or nil (create a task and spawn as usual).
|
|
242
264
|
#
|
|
243
265
|
# Asking first is what keeps the store clean: creating a task and then
|
|
@@ -255,7 +277,7 @@ module Insika
|
|
|
255
277
|
actor.collect(text)
|
|
256
278
|
end
|
|
257
279
|
|
|
258
|
-
#
|
|
280
|
+
# the `steer` door: a message for a session whose turn is ALREADY
|
|
259
281
|
# running is appended to that run instead of becoming a turn of its own.
|
|
260
282
|
# -> the RUNNING task's id (the turn that will answer it), or nil (create a task and
|
|
261
283
|
# spawn as usual, which is `followup`).
|
|
@@ -293,7 +315,7 @@ module Insika
|
|
|
293
315
|
task.id
|
|
294
316
|
end
|
|
295
317
|
|
|
296
|
-
#
|
|
318
|
+
# the `interrupt` door: the turn in flight is answering a question
|
|
297
319
|
# the customer has already replaced, so it is abandoned and the new message becomes an
|
|
298
320
|
# ordinary turn. -> the abandoned task's id, or nil (nothing was running).
|
|
299
321
|
#
|
|
@@ -305,7 +327,7 @@ module Insika
|
|
|
305
327
|
# `:cancel` is observed only at a stage boundary, so a tool call in flight runs to
|
|
306
328
|
# completion and is recorded. Cancelling the not-yet-started calls of a batch would
|
|
307
329
|
# leave it half applied, and fabricating failure results would teach the model that
|
|
308
|
-
# tools failed when they did not (
|
|
330
|
+
# tools failed when they did not (records the same boundary for `turn_timeout`).
|
|
309
331
|
def interrupt_running(session_id, profile:, replaced_by: nil)
|
|
310
332
|
return nil unless @supervised && session_id
|
|
311
333
|
|
|
@@ -329,7 +351,7 @@ module Insika
|
|
|
329
351
|
# its own, by design — it owns scheduling, not persistence).
|
|
330
352
|
attr_reader :task_store
|
|
331
353
|
|
|
332
|
-
#
|
|
354
|
+
# Emitted by the SessionActor when a window closes having merged
|
|
333
355
|
# more than one fragment. `arrivals` are the ISO8601 times each fragment landed
|
|
334
356
|
# — the ONLY record that they were separate messages, since a merged fragment
|
|
335
357
|
# creates no task of its own. Ids and times, never content.
|
|
@@ -337,7 +359,7 @@ module Insika
|
|
|
337
359
|
emit(:turn_coalesced, { task_id: task.id, merged: merged, arrivals: arrivals }, task: task)
|
|
338
360
|
end
|
|
339
361
|
|
|
340
|
-
#
|
|
362
|
+
# a steered message the run could NOT absorb: no tool batch ever
|
|
341
363
|
# closed (a text-only turn), the batch ended in `halt_when`, the turn failed, or it
|
|
342
364
|
# was cancelled. The message is a person's and must not evaporate, so it is released
|
|
343
365
|
# as the next turn on this session — which is `followup`, arrived at late.
|
|
@@ -383,7 +405,7 @@ module Insika
|
|
|
383
405
|
# turn error is already mapped to a terminal state inside its own fiber (single
|
|
384
406
|
# capture); here we only ensure the session loop does not die.
|
|
385
407
|
def run_serial(task, profile:, resume_from: nil)
|
|
386
|
-
#
|
|
408
|
+
# a drain that started with turns already queued behind this
|
|
387
409
|
# session's current one must not keep feeding the loop — without this gate
|
|
388
410
|
# the drain would only converge when the whole backlog ran out.
|
|
389
411
|
return defer_turn(task) if @draining
|
|
@@ -408,15 +430,15 @@ module Insika
|
|
|
408
430
|
nil
|
|
409
431
|
end
|
|
410
432
|
|
|
411
|
-
#
|
|
433
|
+
# runs a CHILD agent turn (called by Tools::Subagent during
|
|
412
434
|
# stage 6). Isolated context (fresh child session), capability NON-inheritance
|
|
413
435
|
# (child profile resolved fresh), environment inheritance (model/thinking seeded
|
|
414
436
|
# from the parent). NEVER raises: a bad agent/depth/child failure is a message
|
|
415
437
|
# to the model, not a turn-killer.
|
|
416
438
|
#
|
|
417
|
-
# async:false (default
|
|
439
|
+
# async:false (default) — SYNCHRONOUS: runs the child inside the parent's
|
|
418
440
|
# fiber and returns { text:, session_id: } (the child result is the tool result).
|
|
419
|
-
# async:true
|
|
441
|
+
# async:true — DURABLE dispatch: spawns the child NON-blocking, persists
|
|
420
442
|
# a Delegation, returns { dispatched:, agent:, session_id: } immediately; the
|
|
421
443
|
# parent turn ends and the child's result is later delivered as a NEW turn on
|
|
422
444
|
# the parent session (needs a delegation_store — else falls back to sync).
|
|
@@ -431,7 +453,7 @@ module Insika
|
|
|
431
453
|
end
|
|
432
454
|
end
|
|
433
455
|
|
|
434
|
-
#
|
|
456
|
+
# (fan-out): runs SEVERAL child turns IN PARALLEL and returns all
|
|
435
457
|
# results together, in the requested order. This is the real latency win — the
|
|
436
458
|
# children overlap their provider waits on the reactor, so wall-clock ≈ the
|
|
437
459
|
# slowest child, not the sum. Always sync-join (a combined result in the parent's
|
|
@@ -449,7 +471,7 @@ module Insika
|
|
|
449
471
|
{ results: spawn_all_and_project(plans, parent_state) }
|
|
450
472
|
end
|
|
451
473
|
|
|
452
|
-
#
|
|
474
|
+
# (boot): reconciles ASYNC delegations after a crash so a
|
|
453
475
|
# completed child's result is never lost. For each undelivered Delegation:
|
|
454
476
|
# · child TERMINAL, not captured -> capture + deliver.
|
|
455
477
|
# · child TERMINAL, captured (completed) -> deliver (crash before delivery).
|
|
@@ -471,7 +493,7 @@ module Insika
|
|
|
471
493
|
{ delivered: delivered }
|
|
472
494
|
end
|
|
473
495
|
|
|
474
|
-
#
|
|
496
|
+
# (boot): re-drives the outbound replies a previous process
|
|
475
497
|
# recorded and never claimed. Records left `delivering` are NOT swept — that
|
|
476
498
|
# process may have POSTed before it died, and re-sending is the duplicate the
|
|
477
499
|
# claim exists to prevent. No-op without a channel_delivery.
|
|
@@ -510,8 +532,17 @@ module Insika
|
|
|
510
532
|
rescue PolicyDenied => e
|
|
511
533
|
emit(:policy_denied, { policy: e.policy, reason: e.reason }, task: task)
|
|
512
534
|
fail_task(task, e, stage: :policy)
|
|
535
|
+
rescue BudgetExceeded => e
|
|
536
|
+
# WS2 hard budget: a typed, retryable failure — the envelope reads
|
|
537
|
+
# budget_exceeded + retry_after (window roll), never a silent drop.
|
|
538
|
+
fail_task(task, e, stage: :budget)
|
|
539
|
+
rescue CircuitOpenError => e
|
|
540
|
+
# WS3 breaker: the turn died BEFORE the provider call — the envelope
|
|
541
|
+
# reads circuit_open + retry_after (cooldown remaining). Worth its own
|
|
542
|
+
# stage: an open breaker is a reliability decision, not an error bug.
|
|
543
|
+
fail_task(task, e, stage: :reliability)
|
|
513
544
|
rescue Insika::WorkflowSchemaError => e
|
|
514
|
-
#
|
|
545
|
+
# a workflow OUTPUT that violates its output_schema. Distinct
|
|
515
546
|
# stage so a contract breach is not conflated with an :unknown failure. (INPUT
|
|
516
547
|
# is validated synchronously in TriggerWorkflow -> 422, never reaches here.)
|
|
517
548
|
fail_task(task, e, stage: :workflow_schema)
|
|
@@ -526,7 +557,15 @@ module Insika
|
|
|
526
557
|
rescue TimeoutError => e
|
|
527
558
|
fail_task(task, e, stage: e.stage)
|
|
528
559
|
rescue StandardError => e
|
|
529
|
-
|
|
560
|
+
# A provider/transport failure is NOT an :unknown bug: wrap it with its
|
|
561
|
+
# action classification (B9) so the envelope can quote retryable and the
|
|
562
|
+
# provider's own retry_after (A8). The classifier is class-name based —
|
|
563
|
+
# the :ruby_llm stage stays reachable even under the smoke-shim's fake.
|
|
564
|
+
if ProviderErrorClassifier.provider_error?(e)
|
|
565
|
+
fail_task(task, ProviderErrorClassifier.wrap(e), stage: :ruby_llm)
|
|
566
|
+
else
|
|
567
|
+
fail_task(task, e, stage: :unknown)
|
|
568
|
+
end
|
|
530
569
|
ensure
|
|
531
570
|
@running.delete(task.id) # ALWAYS deregister (a false-positive running? would break the resume)
|
|
532
571
|
# Deregistered FIRST on purpose: from here on the `steer` door finds no actor for
|
|
@@ -537,7 +576,7 @@ module Insika
|
|
|
537
576
|
|
|
538
577
|
private
|
|
539
578
|
|
|
540
|
-
#
|
|
579
|
+
# resolves session vars > profile.limits > settings["queue"] >
|
|
541
580
|
# defaults. One session read plus one settings read per message, the same
|
|
542
581
|
# order of cost the EdgeLimiter already pays per turn. A missing session (or
|
|
543
582
|
# no session store) simply drops the vars layer.
|
|
@@ -558,7 +597,7 @@ module Insika
|
|
|
558
597
|
SessionActor.new(session_id: session_id, executor: self, parent: turn_parent)
|
|
559
598
|
end
|
|
560
599
|
|
|
561
|
-
#
|
|
600
|
+
# a turn that arrived while the process is draining: left
|
|
562
601
|
# `:queued` on purpose (recovery replays :queued at the next boot). The event
|
|
563
602
|
# is the deferral's only trace — without it, "why was my message answered
|
|
564
603
|
# only after the deploy" is unanswerable.
|
|
@@ -597,9 +636,17 @@ module Insika
|
|
|
597
636
|
# 2.42): blocks on a dequeue that never arrives. Ends only when the scope is
|
|
598
637
|
# stopped (server shutdown) — then any child turns still running go with it.
|
|
599
638
|
# In a deployment that is the LAST resort, not the plan: Insika::Shutdown
|
|
600
|
-
# drains first
|
|
639
|
+
# drains first, so only what outlives the drain deadline dies
|
|
601
640
|
# here, `:running`, for the next boot's recovery to replay.
|
|
602
641
|
@supervisor = node.async { |t| t.annotate("harness-turn-supervisor"); Async::Queue.new.dequeue }
|
|
642
|
+
# the periodic tick is a child of the supervisor — it binds to
|
|
643
|
+
# the serving reactor in every arm with no arm edits, and dies with the
|
|
644
|
+
# supervisor at shutdown (after Shutdown's drain, like any turn).
|
|
645
|
+
@tick&.start(parent: @supervisor)
|
|
646
|
+
# the alert dispatcher (WS6) lives on the same supervisor: its consumer
|
|
647
|
+
# answers every alert event for as long as the process serves.
|
|
648
|
+
@alert_dispatcher&.start(parent: @supervisor)
|
|
649
|
+
@supervisor
|
|
603
650
|
end
|
|
604
651
|
|
|
605
652
|
# Deterministic PendingAction id: correlation by task+turn+tool.
|
|
@@ -608,7 +655,7 @@ module Insika
|
|
|
608
655
|
# checkpointing is a future slice. One call per tool is safe.
|
|
609
656
|
def pending_id(task_id, turn, tool) = "#{task_id}:#{turn}:#{tool}"
|
|
610
657
|
|
|
611
|
-
#
|
|
658
|
+
# all readings of the persisted command go through rebuild_command
|
|
612
659
|
# (the single normalizer). command_type stays a STRING (the :task_started
|
|
613
660
|
# event and the Telemetry attribute are string-typed).
|
|
614
661
|
def command_type(task)
|
|
@@ -641,10 +688,17 @@ module Insika
|
|
|
641
688
|
return nil
|
|
642
689
|
end
|
|
643
690
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
691
|
+
# The task's error record and the :task_failed event both carry the
|
|
692
|
+
# classification when the failure is a wrapped ProviderError (B9/A8):
|
|
693
|
+
# additive fields, absent for every other error.
|
|
694
|
+
classification = error.respond_to?(:classification) ? error.classification : {}
|
|
695
|
+
spec = { class: error.class.name, message: error.message, stage: stage }
|
|
696
|
+
spec = spec.merge(classification) unless classification.empty?
|
|
697
|
+
@task_store.transition(task.id, to: :failed, error: spec)
|
|
698
|
+
data = { task_id: task.id, error: error.class.name, message: error.message }
|
|
699
|
+
data = data.merge(classification) unless classification.empty?
|
|
700
|
+
emit(:task_failed, data, task: task)
|
|
701
|
+
# a FAILED delegation child still delivers — the parent
|
|
648
702
|
# receives an error note as a new turn (never left hanging).
|
|
649
703
|
finalize_delegation(task)
|
|
650
704
|
nil
|
|
@@ -682,7 +736,7 @@ module Insika
|
|
|
682
736
|
end
|
|
683
737
|
|
|
684
738
|
# A Middleware short-circuited (did not call the terminal). Three cases:
|
|
685
|
-
# · halt_response set -> GRACEFUL halt
|
|
739
|
+
# · halt_response set -> GRACEFUL halt: the turn COMPLETES
|
|
686
740
|
# with a safe reply, reusing stages 8-9, without ever touching the LLM.
|
|
687
741
|
# · halt_reason set -> halt-as-FAILURE (the pre-existing contract).
|
|
688
742
|
# · neither -> contract violation (short-circuit with no signal).
|
|
@@ -701,16 +755,16 @@ module Insika
|
|
|
701
755
|
raise Insika::TimeoutError.new("turn exceeded #{turn_timeout}s", stage: :turn)
|
|
702
756
|
end
|
|
703
757
|
|
|
704
|
-
# Builds the turn's mutable state (turn number, message, memory tenant,
|
|
758
|
+
# Builds the turn's mutable state (turn number, message, memory tenant,
|
|
705
759
|
# turn context). before_task (hooks.around) may still rewrite it before stage 2.
|
|
706
760
|
def build_turn_state(task, profile, resume_from)
|
|
707
761
|
turn = resume_from ? resume_from.turn : 1
|
|
708
762
|
state = TurnState.new(task: task, profile: profile, turn: turn,
|
|
709
763
|
message: extract_message(task))
|
|
710
|
-
state.tenant = memory_tenant(task) # WRITE-path memory scope (`remember`); =chat
|
|
711
|
-
state.turn_context = build_turn_context(task, profile, state) # data-tools' ctx.*
|
|
764
|
+
state.tenant = memory_tenant(task) # WRITE-path memory scope (`remember`); =chat
|
|
765
|
+
state.turn_context = build_turn_context(task, profile, state) # data-tools' ctx.*
|
|
712
766
|
state.resumed = !resume_from.nil? # EdgeLimiter: an admitted turn is never re-counted
|
|
713
|
-
#
|
|
767
|
+
# resolved for the RUN, not per message, so what the turn accepts cannot
|
|
714
768
|
# change under it. Same cost as the EdgeLimiter's per-turn resolution. Only for a
|
|
715
769
|
# SESSION turn: steering needs a session to arrive through, and resolving here for a
|
|
716
770
|
# one-shot would make an unrelated turn fail on a queue key it can never use.
|
|
@@ -763,9 +817,87 @@ module Insika
|
|
|
763
817
|
state.requires_approval = resolution.requires_approval
|
|
764
818
|
state.allowed_tools = wrap_tools(assemble_tool_instances(resolution.allowed_tools, state), state, skip)
|
|
765
819
|
state.allowed_skills = resolution.allowed_skills
|
|
820
|
+
record_context_trace(task, state)
|
|
821
|
+
announce_context_skills(task, state)
|
|
766
822
|
drain_and_maybe_suspend(task, actor)
|
|
767
823
|
end
|
|
768
824
|
|
|
825
|
+
# one entry per turn in the ContextTraceStore — tokens per
|
|
826
|
+
# category (the demodulized provider id), the tools-schema estimate and the
|
|
827
|
+
# budget verdict. Counts and ids ONLY, never content. nil store = off; the
|
|
828
|
+
# store itself rescues everything (the trace never breaks the turn).
|
|
829
|
+
def record_context_trace(task, state)
|
|
830
|
+
return unless @context_trace_store && task.session_id
|
|
831
|
+
|
|
832
|
+
package = state.context
|
|
833
|
+
# A custom builder that does not produce the full package (fragments +
|
|
834
|
+
# budget) simply has no breakdown to record — never an error.
|
|
835
|
+
return unless package.respond_to?(:fragments) && package.respond_to?(:budget)
|
|
836
|
+
|
|
837
|
+
categories = package.fragments.each_with_object({}) do |f, acc|
|
|
838
|
+
c = (acc[context_category(f.source)] ||= { tokens: 0, fragments: 0, pinned: 0 })
|
|
839
|
+
c[:tokens] += f.tokens || 0
|
|
840
|
+
c[:fragments] += 1
|
|
841
|
+
c[:pinned] += (f.tokens || 0) if f.pinned
|
|
842
|
+
# WHICH skills/tools the fragment carried and WHY — ids only, still
|
|
843
|
+
# content-free. Without this the trace proves a turn injected N tokens of
|
|
844
|
+
# skill but not which ones, and deterministic activation is unauditable
|
|
845
|
+
# after the fact.
|
|
846
|
+
labels = Array(f.labels)
|
|
847
|
+
(c[:labels] ||= []).concat(labels) unless labels.empty?
|
|
848
|
+
end
|
|
849
|
+
@context_trace_store.record(
|
|
850
|
+
session_id: task.session_id,
|
|
851
|
+
entry: { task_id: task.id, turn: state.turn, at: Time.now.utc.iso8601,
|
|
852
|
+
cap: package.budget[:cap], used: package.budget[:used],
|
|
853
|
+
evicted: package.budget[:evicted], categories: categories,
|
|
854
|
+
tools: { count: state.allowed_tools.size,
|
|
855
|
+
tokens: estimate_tools_tokens(state.allowed_tools) } }
|
|
856
|
+
)
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# Skill bodies that reached the prompt WITHOUT a tool call (`triggers:` or
|
|
860
|
+
# `skills_eager`) leave no trace in the transcript: there is no load_skill to
|
|
861
|
+
# render, so an active skill looked exactly like an absent one.
|
|
862
|
+
#
|
|
863
|
+
# Emitted HERE rather than in the provider because only the Executor holds the
|
|
864
|
+
# correlation the Studio's SSE filters on — an event whose meta lacks `task_id`
|
|
865
|
+
# never reaches a task-scoped subscriber (EventStream::Subscription#matches?).
|
|
866
|
+
# `skills` (plural, with reasons) marks the CONTEXT path; the load_skill tool
|
|
867
|
+
# emits the same type with a singular `name`, and the Studio must not conflate them.
|
|
868
|
+
#
|
|
869
|
+
# Read from `package.fragments`, which is POST-BUDGET: a body the cut evicted is
|
|
870
|
+
# not in the prompt, and announcing it as active would make the one surface built
|
|
871
|
+
# to tell the truth the one that lies. Eviction is reported by the trace's own
|
|
872
|
+
# `evicted` list, never as an activation.
|
|
873
|
+
SKILL_BODY_CATEGORY = "skilltrigger"
|
|
874
|
+
|
|
875
|
+
def announce_context_skills(task, state)
|
|
876
|
+
package = state.context
|
|
877
|
+
return unless package.respond_to?(:fragments)
|
|
878
|
+
|
|
879
|
+
skills = Array(package.fragments)
|
|
880
|
+
.select { |f| context_category(f.source) == SKILL_BODY_CATEGORY }
|
|
881
|
+
.flat_map { |f| Array(f.labels) }
|
|
882
|
+
.map { |l| { name: l["name"], reason: l["reason"] || "pack" } }
|
|
883
|
+
.uniq
|
|
884
|
+
return if skills.empty?
|
|
885
|
+
|
|
886
|
+
emit(:skill_activated, { skills: skills, source: "context" }, task: task)
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
# "Insika::Context::Providers::Prompt" -> "prompt" (a plugin provider keeps
|
|
890
|
+
# its own demodulized name — still content-free).
|
|
891
|
+
def context_category(source) = source.to_s.split("::").last.to_s.downcase
|
|
892
|
+
|
|
893
|
+
# Same yardstick as the fragments (TokenEstimator), so the categories are
|
|
894
|
+
# comparable. `parameters` is not guaranteed JSON-safe — inspect it.
|
|
895
|
+
def estimate_tools_tokens(tools)
|
|
896
|
+
TokenEstimator.estimate(tools.map { |t| "#{t.name} #{t.description} #{t.parameters.inspect}" }.join(" "))
|
|
897
|
+
rescue StandardError
|
|
898
|
+
0
|
|
899
|
+
end
|
|
900
|
+
|
|
769
901
|
# Stages 5-9 (inside the Middleware wrap): assemble chat, the single agent
|
|
770
902
|
# interaction, persistence, terminal event. `st` is the Middleware-yielded state.
|
|
771
903
|
def run_turn_body(task, profile, st, actor, timing = nil)
|
|
@@ -775,11 +907,11 @@ module Insika
|
|
|
775
907
|
unless workflow_turn?(task)
|
|
776
908
|
st.chat = create_chat(profile, st)
|
|
777
909
|
@chat_builder.assemble(st.chat, st, emit: ->(type, data) { emit(type, data, task: task) })
|
|
778
|
-
#
|
|
910
|
+
# R1: baseline = seeded-history size, before `ask` appends the turn.
|
|
779
911
|
st.chat_baseline = Array(st.chat.messages).size if st.chat.respond_to?(:messages)
|
|
780
912
|
end
|
|
781
913
|
|
|
782
|
-
# guardrails
|
|
914
|
+
# guardrails: per-turn stream redactor (nil = off).
|
|
783
915
|
st.output_filter = @content_filter_factory&.call(st)
|
|
784
916
|
|
|
785
917
|
# stage 6: the turn's single agent interaction (send_message -> chat.ask;
|
|
@@ -798,6 +930,15 @@ module Insika
|
|
|
798
930
|
timing&.mark(:done)
|
|
799
931
|
data = { task_id: task.id, content: content, usage: st.usage }
|
|
800
932
|
data[:timing] = timing.to_h if timing # opt-in TTFB breakdown (INSIKA_TURN_TIMING)
|
|
933
|
+
# WS5: the agent declared it cannot proceed (signal_stuck). The turn still
|
|
934
|
+
# COMPLETES (its final message was published) — but the consumer must be able
|
|
935
|
+
# to act on that, so the contract carries it twice: a dedicated :turn_stuck
|
|
936
|
+
# event (subscribable) and an additive `outcome` sibling on the terminal event.
|
|
937
|
+
if (stuck = st.stuck_outcome)
|
|
938
|
+
emit(:turn_stuck, { task_id: task.id, agent: profile.id.to_s,
|
|
939
|
+
reason: stuck[:reason], message: content }, task: task)
|
|
940
|
+
data[:outcome] = :stuck
|
|
941
|
+
end
|
|
801
942
|
emit(:task_completed, data, task: task)
|
|
802
943
|
end
|
|
803
944
|
|
|
@@ -811,7 +952,7 @@ module Insika
|
|
|
811
952
|
# workflow = a Ruby callable that orchestrates RubyLLM internally (RubyLLM
|
|
812
953
|
# First). tools: are the SAME instances filtered by the Resolution and
|
|
813
954
|
# enveloped (stage 7) — the workflow inherits timeout/side-effect/skip.
|
|
814
|
-
#
|
|
955
|
+
# the EXPOSED surface — the run (== task.id) is announced on
|
|
815
956
|
# the stream (:workflow_started), the RETURN is validated against the
|
|
816
957
|
# output_schema (WorkflowSchemaError -> :workflow_schema stage), and the
|
|
817
958
|
# typed output is published (:workflow_completed).
|
|
@@ -826,32 +967,20 @@ module Insika
|
|
|
826
967
|
emit(:workflow_completed, { run_id: task.id, workflow: definition.name, output: output }, task: task)
|
|
827
968
|
output
|
|
828
969
|
else
|
|
829
|
-
filter = state.output_filter #
|
|
970
|
+
filter = state.output_filter # nil = off (stream untouched)
|
|
830
971
|
timing&.mark(:ask)
|
|
831
972
|
# TurnOutput owns what the customer is allowed to read: chunks ride
|
|
832
973
|
# :intermediate live and only the message that ENDS the turn is published as
|
|
833
|
-
# :content. Registered on the chat (fresh per turn,
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
state.chat.after_message { |message| output.message_ended(message) } if state.chat.respond_to?(:after_message)
|
|
837
|
-
# RFC-0015 §5.2: `steer` only. Registered AFTER TurnOutput so the publishing
|
|
838
|
-
# decision for a message is made before anything is appended after it — the
|
|
839
|
-
# gem's callbacks are additive and run in registration order.
|
|
840
|
-
install_steer_injector(task, state)
|
|
841
|
-
|
|
842
|
-
# `asked` is what the provider returned, BEFORE the :agent after-hook had a
|
|
843
|
-
# chance to replace it — the only way to tell an explicit substitution from
|
|
844
|
-
# the ordinary "the hook returned what it received".
|
|
974
|
+
# :content. Registered on the chat (fresh per turn/attempt, no leak).
|
|
975
|
+
# With WS3 reliability the attempts build their own chats + outputs.
|
|
976
|
+
output = nil
|
|
845
977
|
asked = nil
|
|
846
978
|
response = @hooks.around(:agent, state) do |s|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
timing&.mark(:first_token) # first-write-wins -> the PROVIDER's TTFB
|
|
853
|
-
output.push(chunk.content)
|
|
854
|
-
end
|
|
979
|
+
result = @reliability ? run_reliable_ask(task, s, filter, timing)
|
|
980
|
+
: run_single_ask(task, s, filter, timing)
|
|
981
|
+
output = result[:output]
|
|
982
|
+
asked = result[:asked]
|
|
983
|
+
result[:response]
|
|
855
984
|
end
|
|
856
985
|
# release the redactor's retained tail (a value that never completed into a
|
|
857
986
|
# match is emitted redacted-if-needed, not lost) before reading anything back.
|
|
@@ -862,7 +991,7 @@ module Insika
|
|
|
862
991
|
# was working used to be observed at stage 8 — AFTER `:content` had already been
|
|
863
992
|
# published — so the customer read the answer of a turn that then terminated
|
|
864
993
|
# `:cancelled` and persisted nothing: text delivered, transcript silent about it.
|
|
865
|
-
# Honoring it here is what makes `interrupt`
|
|
994
|
+
# Honoring it here is what makes `interrupt` mean anything, and it
|
|
866
995
|
# is a safe boundary: the tool batch is finished and nothing is half applied.
|
|
867
996
|
# A `:pause` is deliberately NOT honored here (drain!, not the suspending form):
|
|
868
997
|
# holding a completed answer for an operator would strand it unpublished.
|
|
@@ -872,7 +1001,168 @@ module Insika
|
|
|
872
1001
|
end
|
|
873
1002
|
end
|
|
874
1003
|
|
|
875
|
-
#
|
|
1004
|
+
# stage 6, plain path: the single ask on the assembled chat. Fresh TurnOutput
|
|
1005
|
+
# + steer wiring per interaction (registered on state.chat, which the solve
|
|
1006
|
+
# already assembled). -> { response:, asked:, output: }.
|
|
1007
|
+
def run_single_ask(task, state, filter, timing)
|
|
1008
|
+
output = new_turn_output(task, state, filter)
|
|
1009
|
+
wire_chat_output(task, state, output)
|
|
1010
|
+
asked = ask_on(task, state, state.chat, output, timing)
|
|
1011
|
+
{ response: asked, asked: asked, output: output }
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
# stage 6, WS3 path: the Reliability coordinator drives retries, backoff,
|
|
1015
|
+
# circuit breaker and the fallback rotation. Each ATTEMPT gets a fresh chat
|
|
1016
|
+
# + output (a failed `ask` leaves its message in the chat, so re-asking the
|
|
1017
|
+
# same one would double the input) and, on a fallback, state.model_selection
|
|
1018
|
+
# follows — the turn's usage is attributed to the model that actually spoke
|
|
1019
|
+
# ("contabilizado no trace"). -> { response:, asked:, output: }.
|
|
1020
|
+
def run_reliable_ask(task, state, filter, timing)
|
|
1021
|
+
policy = state.profile.respond_to?(:reliability) ? state.profile.reliability : nil
|
|
1022
|
+
return run_single_ask(task, state, filter, timing) if policy.nil? || @reliability.nil?
|
|
1023
|
+
|
|
1024
|
+
attempt_output = nil
|
|
1025
|
+
attempt_asked = nil
|
|
1026
|
+
primary = state.model_selection
|
|
1027
|
+
response = @reliability.call(
|
|
1028
|
+
policy: policy, tenant: task_tenant(task), agent: state.profile.id.to_s,
|
|
1029
|
+
selection: primary, chain: reliability_chain(state)
|
|
1030
|
+
) do |selection, tries|
|
|
1031
|
+
first_attempt = state.chat && selection == primary && tries == 1
|
|
1032
|
+
if selection != primary
|
|
1033
|
+
state.model_selection = selection # attribution follows the fallback
|
|
1034
|
+
end
|
|
1035
|
+
unless first_attempt
|
|
1036
|
+
chat = build_attempt_chat(state, selection)
|
|
1037
|
+
state.chat = chat
|
|
1038
|
+
end
|
|
1039
|
+
attempt_output = new_turn_output(task, state, filter)
|
|
1040
|
+
wire_chat_output(task, state, attempt_output)
|
|
1041
|
+
attempt_asked = ask_on(task, state, state.chat, attempt_output, timing)
|
|
1042
|
+
attempt_asked
|
|
1043
|
+
end
|
|
1044
|
+
{ response: response, asked: attempt_asked, output: attempt_output }
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
# The fallback chain for WS3: profile's `reliability["fallback"]` refs first,
|
|
1048
|
+
# then the platform-resolved fallbacks (ModelSelection). Each node is a
|
|
1049
|
+
# ModelSelection (the SAME duck the primary is — usage attribution and
|
|
1050
|
+
# apply_params just work), source: :fallback, params inherited from the
|
|
1051
|
+
# primary. Deduped by ref, primary excluded.
|
|
1052
|
+
def reliability_chain(state)
|
|
1053
|
+
primary = state.model_selection
|
|
1054
|
+
refs = Array((state.profile.reliability || {})["fallback"]).map(&:to_s)
|
|
1055
|
+
nodes = refs.filter_map { |r| parse_model_ref(r) }.reject { |n| n[:model].to_s.empty? }
|
|
1056
|
+
nodes.concat(Array(primary.fallbacks).map { |f| { model: f[:model], provider: f[:provider] } })
|
|
1057
|
+
seen = { ref_of(primary) => true }
|
|
1058
|
+
nodes.filter_map do |node|
|
|
1059
|
+
ref = model_ref(node)
|
|
1060
|
+
# normalize "model" vs "provider/model": a provider-less ref IS the same
|
|
1061
|
+
# physical model as any known "provider/model" spelling of it — the same
|
|
1062
|
+
# model must never be tried twice just because one spelling omits the
|
|
1063
|
+
# provider (WS3: fallback ["deepseek-v4-flash"] under primary
|
|
1064
|
+
# deepseek/deepseek-v4-flash used to re-ask the dropped primary). A
|
|
1065
|
+
# qualified ref still matches exactly.
|
|
1066
|
+
duplicate = ref.include?("/") ? seen[ref]
|
|
1067
|
+
: seen.keys.any? { |known| known.split("/").last == ref }
|
|
1068
|
+
next if duplicate
|
|
1069
|
+
|
|
1070
|
+
seen[ref] = true
|
|
1071
|
+
Insika::ModelSelection.new(model: node[:model], provider: node[:provider],
|
|
1072
|
+
source: :fallback, params: primary.params, fallbacks: [])
|
|
1073
|
+
end
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
def ref_of(selection) = model_ref(selection)
|
|
1077
|
+
|
|
1078
|
+
# "provider/model" for any selection duck (ModelSelection | { model:, provider: }).
|
|
1079
|
+
def model_ref(selection)
|
|
1080
|
+
model = selection.respond_to?(:model) ? selection.model.to_s : selection[:model].to_s
|
|
1081
|
+
provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
|
|
1082
|
+
provider ? "#{provider}/#{model}" : model
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
# "provider/model" -> { model:, provider: }; "model" -> { model:, provider: nil }.
|
|
1086
|
+
def parse_model_ref(entry)
|
|
1087
|
+
s = entry.to_s.strip
|
|
1088
|
+
return nil if s.empty?
|
|
1089
|
+
|
|
1090
|
+
if s.include?("/")
|
|
1091
|
+
provider, model = s.split("/", 2)
|
|
1092
|
+
{ model: model, provider: presence_or_nil(provider)&.to_sym }
|
|
1093
|
+
else
|
|
1094
|
+
{ model: s, provider: nil }
|
|
1095
|
+
end
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
def presence_or_nil(value)
|
|
1099
|
+
v = value.to_s.strip
|
|
1100
|
+
v.empty? ? nil : v
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
# A fresh chat for a retry/fallback attempt: REASSEMBLED from the same turn
|
|
1104
|
+
# state (the seed history is identical), baseline reset -> the transcript
|
|
1105
|
+
# recorded from than point is the attempt that spoke.
|
|
1106
|
+
def build_attempt_chat(state, selection)
|
|
1107
|
+
chat = build_chat(selection, state.model_selection)
|
|
1108
|
+
@chat_builder.assemble(chat, state, emit: ->(type, data) { emit(type, data, task: state.task) })
|
|
1109
|
+
state.chat_baseline = Array(chat.messages).size if chat.respond_to?(:messages)
|
|
1110
|
+
chat
|
|
1111
|
+
end
|
|
1112
|
+
|
|
1113
|
+
def new_turn_output(task, state, filter)
|
|
1114
|
+
TurnOutput.new(filter: filter, emit: ->(type, data) { emit(type, data, task: task) },
|
|
1115
|
+
public_intermediate: state.profile.stream_public?(:intermediate))
|
|
1116
|
+
end
|
|
1117
|
+
|
|
1118
|
+
# The message-boundary + steer wiring ON the current chat. Registered
|
|
1119
|
+
# AFTER TurnOutput so the publishing decision for a message is made before
|
|
1120
|
+
# anything is appended after it — the gem's callbacks are additive and run
|
|
1121
|
+
# in registration order.
|
|
1122
|
+
def wire_chat_output(task, state, output)
|
|
1123
|
+
chat = state.chat
|
|
1124
|
+
chat.after_message { |message| output.message_ended(message) } if chat.respond_to?(:after_message)
|
|
1125
|
+
install_steer_injector(task, state)
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
# The ask itself, chunk-by-chunk (WS3 attempts and the plain path share it).
|
|
1129
|
+
# With INSIKA_TURN_TIMING the FIRST content chunk also emits the live
|
|
1130
|
+
# :ttft event — the streaming envelope's TTFB signal (WS6), additive. The
|
|
1131
|
+
# emit is gated to that first chunk: a probe proved the old code re-emitted
|
|
1132
|
+
# :ttft on EVERY content chunk (3 chunks = 3 insika.ttft frames); the spec
|
|
1133
|
+
# passed because FakeChat emits a single chunk.
|
|
1134
|
+
def ask_on(task, state, chat, output, timing)
|
|
1135
|
+
public_thinking = state.profile.stream_public?(:thinking)
|
|
1136
|
+
ttft_sent = false
|
|
1137
|
+
chat.ask(state.message) do |chunk|
|
|
1138
|
+
emit_thinking(chunk, task, public: public_thinking)
|
|
1139
|
+
next unless chunk.content
|
|
1140
|
+
|
|
1141
|
+
timing&.mark(:first_token) # first-write-wins -> the PROVIDER's TTFB
|
|
1142
|
+
unless ttft_sent
|
|
1143
|
+
emit_ttft(task, timing) if timing
|
|
1144
|
+
ttft_sent = true
|
|
1145
|
+
end
|
|
1146
|
+
output.push(chunk.content)
|
|
1147
|
+
end
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
# The provider's TTFB as a live event (data: ttft_ms) — only under
|
|
1151
|
+
# INSIKA_TURN_TIMING, so absent by default (parity).
|
|
1152
|
+
def emit_ttft(task, timing)
|
|
1153
|
+
ttft = timing.to_h[:ttft_ms]
|
|
1154
|
+
return if ttft.nil?
|
|
1155
|
+
|
|
1156
|
+
@event_stream.emit(Insika::Event.new(
|
|
1157
|
+
type: :ttft, data: { ttft_ms: ttft },
|
|
1158
|
+
meta: { task_id: task.id, session_id: task.session_id,
|
|
1159
|
+
at: Time.now.utc.iso8601 }
|
|
1160
|
+
))
|
|
1161
|
+
rescue StandardError
|
|
1162
|
+
nil
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
# wires the tool-batch boundary that lets a message which arrived
|
|
876
1166
|
# mid-run enter the conversation. No-op unless the agent asked for `steer`: an
|
|
877
1167
|
# unregistered callback is the difference between a feature that is off and one that
|
|
878
1168
|
# is on and finds nothing.
|
|
@@ -948,9 +1238,9 @@ module Insika
|
|
|
948
1238
|
#
|
|
949
1239
|
# Three deliberate omissions:
|
|
950
1240
|
# · the guardrail filter is NOT applied — it accumulates the PERSISTED content
|
|
951
|
-
#
|
|
1241
|
+
# and pushing reasoning through it would corrupt the turn's answer;
|
|
952
1242
|
# · `timing.mark(:first_token)` stays on the content chunks — ttft is the
|
|
953
|
-
# PROVIDER's first token (
|
|
1243
|
+
# PROVIDER's first token ('s baselines measure that, not the first
|
|
954
1244
|
# thought, and not when TurnOutput publishes the answer);
|
|
955
1245
|
# · nothing is persisted — the reasoning is not part of the conversation.
|
|
956
1246
|
#
|
|
@@ -967,7 +1257,7 @@ module Insika
|
|
|
967
1257
|
emit(:thinking, data, task: task)
|
|
968
1258
|
end
|
|
969
1259
|
|
|
970
|
-
# Annotates the usage with the RESOLVED model-selection source
|
|
1260
|
+
# Annotates the usage with the RESOLVED model-selection source:
|
|
971
1261
|
# where the model came from (:chat/:agent/:platform_default) travels alongside
|
|
972
1262
|
# the resolved model id (from the provider) into the terminal event/Telemetry,
|
|
973
1263
|
# so billing/telemetry can attribute the turn to a config layer. nil usage
|
|
@@ -994,7 +1284,7 @@ module Insika
|
|
|
994
1284
|
if response.respond_to?(:cached_tokens) && response.cached_tokens
|
|
995
1285
|
usage[:cached_tokens] = response.cached_tokens.to_i # cache_read_input_tokens
|
|
996
1286
|
end
|
|
997
|
-
#
|
|
1287
|
+
# R3: prompt-cache WRITE tokens (Anthropic cache_creation_input_tokens),
|
|
998
1288
|
# billed at ~1.25x. Reported so the first (write) turn vs later (read) turns
|
|
999
1289
|
# are distinguishable in telemetry/usage.
|
|
1000
1290
|
if response.respond_to?(:cache_creation_tokens) && response.cache_creation_tokens
|
|
@@ -1022,7 +1312,7 @@ module Insika
|
|
|
1022
1312
|
|
|
1023
1313
|
def build_context_request(task, profile, state, resume_from)
|
|
1024
1314
|
session = task.session_id ? @session_store.find(task.session_id) : nil
|
|
1025
|
-
state.session = session # create_chat reads it for the per-chat model pin
|
|
1315
|
+
state.session = session # create_chat reads it for the per-chat model pin
|
|
1026
1316
|
hist = command_history(task)
|
|
1027
1317
|
# `vars` reconciles the seam (the Request/Session provider already
|
|
1028
1318
|
# called request.vars): session metadata + the explicit `history` in the
|
|
@@ -1036,7 +1326,7 @@ module Insika
|
|
|
1036
1326
|
checkpoint: resume_from, tenant: command_tenant(task), vars: vars)
|
|
1037
1327
|
end
|
|
1038
1328
|
|
|
1039
|
-
#
|
|
1329
|
+
# task_started payload. Carries the EXPLICIT command tenant so
|
|
1040
1330
|
# the observability convention can group by it — the one operator-set label that
|
|
1041
1331
|
# is not derivable from the task itself. Omitted when absent: the terminal
|
|
1042
1332
|
# events keep their shape and no consumer sees a null it never saw before. NOT
|
|
@@ -1062,7 +1352,7 @@ module Insika
|
|
|
1062
1352
|
Coercion.presence(rebuild_command(task).payload["origin"])
|
|
1063
1353
|
end
|
|
1064
1354
|
|
|
1065
|
-
# Engine memory scope
|
|
1355
|
+
# Engine memory scope: the Command's EXPLICIT tenant wins (multi-merchant
|
|
1066
1356
|
# override); otherwise the SESSION (=chat) — engine-owner memory is per-chat.
|
|
1067
1357
|
# Symmetric to the READ path (Memory provider). One-shot with no tenant -> nil
|
|
1068
1358
|
# (_default). It is NOT the <request_context> tenant (that follows
|
|
@@ -1071,35 +1361,35 @@ module Insika
|
|
|
1071
1361
|
command_tenant(task) || task.session_id
|
|
1072
1362
|
end
|
|
1073
1363
|
|
|
1074
|
-
# Turn context
|
|
1364
|
+
# Turn context: the ids the data-tools resolve via
|
|
1075
1365
|
# {{ctx.*}} to emit X-Chat-Id/X-Store-Id/X-Agent-Id to /api/internal/*. They
|
|
1076
1366
|
# come from the TURN, never from the model args (R2). chat_id = the session
|
|
1077
1367
|
# (the /v1/responses adapter creates the session with id = user = chat.id);
|
|
1078
1368
|
# tenant = the Command tenant (memory) OR chat_id (drop-in default); agent_id =
|
|
1079
1369
|
# profile; store_id = the profile metadata (stable per store, from the pack).
|
|
1080
1370
|
# Absent fields -> nil (the data-tool emits an empty header; in the pilot the
|
|
1081
|
-
# profile carries store_id). Generic: nothing here mentions
|
|
1371
|
+
# profile carries store_id). Generic: nothing here mentions a consumer.
|
|
1082
1372
|
def build_turn_context(task, profile, state)
|
|
1083
1373
|
{
|
|
1084
1374
|
chat_id: task.session_id,
|
|
1085
1375
|
agent_id: profile.id,
|
|
1086
1376
|
tenant: state.tenant, # already = command_tenant || session_id (memory_tenant)
|
|
1087
1377
|
store_id: profile.store_id,
|
|
1088
|
-
#
|
|
1378
|
+
# current delegation depth (0 for a top-level turn). Carried in
|
|
1089
1379
|
# the child command's payload by run_subagent; read here so the child's OWN
|
|
1090
1380
|
# spawn_subagent tool sees depth+1 and the runtime cap holds down the chain.
|
|
1091
1381
|
delegation_depth: delegation_depth(task)
|
|
1092
1382
|
}
|
|
1093
1383
|
end
|
|
1094
1384
|
|
|
1095
|
-
# Delegation depth of THIS turn
|
|
1385
|
+
# Delegation depth of THIS turn: the value run_subagent stamped in
|
|
1096
1386
|
# the child command, or 0 for a top-level turn. Integer-coerced (JSON round-trip
|
|
1097
1387
|
# of the persisted command may deliver a String).
|
|
1098
1388
|
def delegation_depth(task)
|
|
1099
1389
|
rebuild_command(task).payload["delegation_depth"].to_i
|
|
1100
1390
|
end
|
|
1101
1391
|
|
|
1102
|
-
#
|
|
1392
|
+
# R2: environment (model/thinking) inherits as DEFAULT — the child's
|
|
1103
1393
|
# explicit value wins; when absent, seed from the parent's RESOLVED selection.
|
|
1104
1394
|
# Capacity fields are untouched (R1: the child profile is used as-is). Returns
|
|
1105
1395
|
# the child profile unchanged when there is nothing to inherit.
|
|
@@ -1120,7 +1410,7 @@ module Insika
|
|
|
1120
1410
|
child_profile.with(model: model, provider: provider, params: params)
|
|
1121
1411
|
end
|
|
1122
1412
|
|
|
1123
|
-
# Single validation path for a delegation
|
|
1413
|
+
# Single validation path for a delegation — shared by run_subagent
|
|
1124
1414
|
# and the fan-out run_subagents. `task` is {agent, message} (string OR symbol
|
|
1125
1415
|
# keys — the model's args arrive string-keyed). Returns a resolved plan
|
|
1126
1416
|
# { agent:, profile:, message:, depth: } or { agent:, error: } (the agent name is
|
|
@@ -1193,7 +1483,7 @@ module Insika
|
|
|
1193
1483
|
[child_session_id, child_task]
|
|
1194
1484
|
end
|
|
1195
1485
|
|
|
1196
|
-
# SYNC
|
|
1486
|
+
# SYNC: spawns the child and AWAITS it on the parent's fiber, then
|
|
1197
1487
|
# projects the terminal content. Direct `spawn` (not spawn_in_session): the
|
|
1198
1488
|
# child session is brand-new, so there is no SessionActor contention — the child
|
|
1199
1489
|
# is parented at turn_parent and the parent yields cooperatively on `wait`.
|
|
@@ -1204,7 +1494,7 @@ module Insika
|
|
|
1204
1494
|
project_child_result(child_task.id, child_session_id, child_profile.id, parent_state)
|
|
1205
1495
|
end
|
|
1206
1496
|
|
|
1207
|
-
# ASYNC
|
|
1497
|
+
# ASYNC: persists a Delegation, spawns the child NON-blocking, and
|
|
1208
1498
|
# returns a dispatch ack immediately — the parent turn ends without waiting. The
|
|
1209
1499
|
# child's terminal hook (finalize_delegation) delivers the result later, as a
|
|
1210
1500
|
# NEW turn on the parent session.
|
|
@@ -1253,7 +1543,7 @@ module Insika
|
|
|
1253
1543
|
exec.error && (exec.error["message"] || exec.error[:message])
|
|
1254
1544
|
end
|
|
1255
1545
|
|
|
1256
|
-
#
|
|
1546
|
+
# terminal hook: when a turn ends (success OR failure), if the
|
|
1257
1547
|
# task is the child of an ASYNC delegation, capture its result and deliver it to
|
|
1258
1548
|
# the parent. Fires for both a normal completion and a resumed one (recovery),
|
|
1259
1549
|
# so it needs no live watcher fiber. No-op without a delegation_store or when the
|
|
@@ -1337,12 +1627,14 @@ module Insika
|
|
|
1337
1627
|
command: rebuild_command(task),
|
|
1338
1628
|
context: state.context,
|
|
1339
1629
|
candidate_tools: @tool_registry.entries,
|
|
1340
|
-
|
|
1630
|
+
# agent: so a specialized skill reaches the policy as the agent's own version
|
|
1631
|
+
# (same name, its body) instead of the shared one it overrides.
|
|
1632
|
+
candidate_skills: @skill_catalog.effective(profile.skills, agent: profile.id)
|
|
1341
1633
|
)
|
|
1342
1634
|
end
|
|
1343
1635
|
|
|
1344
1636
|
# The Task persists the Command as a Hash; the WorkflowAllowlist needs
|
|
1345
|
-
# a Command with #type (Symbol) and #payload
|
|
1637
|
+
# a Command with #type (Symbol) and #payload.: the SINGLE point that
|
|
1346
1638
|
# reconciles the string||symbol keys of the persisted command — payload/meta
|
|
1347
1639
|
# keys are stringified ONCE here, so every reader (command_type/workflow_name/
|
|
1348
1640
|
# extract_message/command_history/command_tenant) works with string keys.
|
|
@@ -1362,7 +1654,7 @@ module Insika
|
|
|
1362
1654
|
(hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
1363
1655
|
end
|
|
1364
1656
|
|
|
1365
|
-
# Stage-3-tail tool assembly — delegated to ToolAssembly
|
|
1657
|
+
# Stage-3-tail tool assembly — delegated to ToolAssembly. Kept as
|
|
1366
1658
|
# thin private methods so the existing spec contract (executor.send(:...))
|
|
1367
1659
|
# stays intact and run_pipeline reads unchanged.
|
|
1368
1660
|
def resolve_capabilities(profile, context) = @tool_assembly.resolve_capabilities(profile, context)
|
|
@@ -1403,7 +1695,7 @@ module Insika
|
|
|
1403
1695
|
))
|
|
1404
1696
|
end
|
|
1405
1697
|
|
|
1406
|
-
# GRACEFUL halt
|
|
1698
|
+
# GRACEFUL halt: a Middleware short-circuited with a safe reply.
|
|
1407
1699
|
# The turn COMPLETES — same stages 8-9 as a normal turn — but the "assistant
|
|
1408
1700
|
# content" is the guardrail's safe response, produced with ZERO LLM calls. The
|
|
1409
1701
|
# order mirrors a real turn so both the /v1/responses consumer (which reads the
|
|
@@ -1420,11 +1712,11 @@ module Insika
|
|
|
1420
1712
|
}, task: task)
|
|
1421
1713
|
end
|
|
1422
1714
|
emit(:content, { delta: content }, task: task) unless content.empty?
|
|
1423
|
-
# An EDGE-blocked turn (rate limit / token ceiling
|
|
1715
|
+
# An EDGE-blocked turn (rate limit / token ceiling) completes but
|
|
1424
1716
|
# stays OUT of the session history: a flood at the wall must not bloat the
|
|
1425
1717
|
# session nor evict real conversation from the context budget — the
|
|
1426
1718
|
# :guardrail_blocked event is the audit trail. Content-guardrail blocks
|
|
1427
|
-
# keep persisting (
|
|
1719
|
+
# keep persisting (the refusal is part of the conversation).
|
|
1428
1720
|
# The reply is the guardrail's, produced with zero LLM calls — so it is NOT the
|
|
1429
1721
|
# agent talking, and a report that counts it as the agent repeating itself is
|
|
1430
1722
|
# reading the engine's own canned text (the `safe_reply` finding exists exactly
|
|
@@ -1452,7 +1744,7 @@ module Insika
|
|
|
1452
1744
|
# Recovery re-executes the already-saved turn (safe thanks to the side-effect
|
|
1453
1745
|
# recording).
|
|
1454
1746
|
#
|
|
1455
|
-
# CHECKPOINT vs SESSION — the two stores DIVERGE by design (
|
|
1747
|
+
# CHECKPOINT vs SESSION — the two stores DIVERGE by design (R2c):
|
|
1456
1748
|
# · Checkpoint.messages = flatten_history(context.history) + new_messages,
|
|
1457
1749
|
# i.e. "what the model actually SAW this turn" AFTER budget eviction
|
|
1458
1750
|
# (context.history is the post-budget assembly). It is the deterministic
|
|
@@ -1493,12 +1785,12 @@ module Insika
|
|
|
1493
1785
|
|
|
1494
1786
|
emit(:checkpoint_created, { task_id: task.id, turn: state.turn + 1 }, task: task)
|
|
1495
1787
|
|
|
1496
|
-
#
|
|
1788
|
+
# if this completed turn is an ASYNC delegation child,
|
|
1497
1789
|
# deliver its result to the parent as a NEW turn. No-op for a normal turn
|
|
1498
1790
|
# (not a delegation child) or without a delegation_store.
|
|
1499
1791
|
finalize_delegation(task)
|
|
1500
1792
|
|
|
1501
|
-
#
|
|
1793
|
+
# if this turn CAME IN through a Shape B channel, its answer
|
|
1502
1794
|
# has to travel out of band. Same terminal hook, next door to the delegation
|
|
1503
1795
|
# one, for the same reason: it fires for a fresh turn and a recovered one.
|
|
1504
1796
|
finalize_channel_delivery(task, content)
|
|
@@ -1508,7 +1800,7 @@ module Insika
|
|
|
1508
1800
|
# turn's TRANSPORT (`channel:<id>` on the persisted command), not the session:
|
|
1509
1801
|
# a session belongs to the channel forever, but a message an operator types into
|
|
1510
1802
|
# the Studio playground against that same session must not reach the customer.
|
|
1511
|
-
# Human handoff is not a product feature (
|
|
1803
|
+
# Human handoff is not a product feature (``), and it would be a
|
|
1512
1804
|
# surprising way to acquire one.
|
|
1513
1805
|
#
|
|
1514
1806
|
# The consequence, stated rather than discovered later: a turn the ENGINE
|
|
@@ -1553,15 +1845,15 @@ module Insika
|
|
|
1553
1845
|
transport.start_with?("channel:") ? transport.delete_prefix("channel:") : nil
|
|
1554
1846
|
end
|
|
1555
1847
|
|
|
1556
|
-
# Truncation cap for a persisted `role: tool` content (
|
|
1848
|
+
# Truncation cap for a persisted `role: tool` content (R1): the transcript
|
|
1557
1849
|
# keeps the loop coherent; the FULL result lives in the ToolTraceStore (viewer).
|
|
1558
1850
|
TOOL_CONTENT_CAP = 4_000
|
|
1559
1851
|
|
|
1560
|
-
# The turn's messages in the ADDITIVE string-keyed format (
|
|
1852
|
+
# The turn's messages in the ADDITIVE string-keyed format (R1). Prefers the
|
|
1561
1853
|
# real chat transcript (`chat.messages.drop(baseline)`) so tool calls/results
|
|
1562
1854
|
# survive between turns; falls back to the {user, assistant} pair when the chat
|
|
1563
1855
|
# did not record the turn (workflow, graceful halt, or the specs' FakeChat).
|
|
1564
|
-
# The final assistant text is the REDACTED `content` (output_filter
|
|
1856
|
+
# The final assistant text is the REDACTED `content` (output_filter),
|
|
1565
1857
|
# never the raw text the gem stored.
|
|
1566
1858
|
# `origin` (MessageOrigin) travels on the turn's Command and is stamped on the
|
|
1567
1859
|
# message it describes: the INCOMING one. It is absent for an ordinary turn, and
|
|
@@ -1637,7 +1929,7 @@ module Insika
|
|
|
1637
1929
|
end
|
|
1638
1930
|
|
|
1639
1931
|
# context.history may carry "eviction units" (an assistant+tool_results cycle
|
|
1640
|
-
# grouped as one Array by the Session provider,
|
|
1932
|
+
# grouped as one Array by the Session provider, R1). Checkpoints store a
|
|
1641
1933
|
# FLAT list — the provider regroups on read. Flatten one level; message Hashes
|
|
1642
1934
|
# are untouched.
|
|
1643
1935
|
def flatten_history(history) = Array(history).flatten(1)
|
|
@@ -1653,28 +1945,51 @@ module Insika
|
|
|
1653
1945
|
require_relative "tools/remember"
|
|
1654
1946
|
require_relative "tools/subagent"
|
|
1655
1947
|
require_relative "tools/subagents"
|
|
1656
|
-
|
|
1948
|
+
require_relative "tools/stuck_signal"
|
|
1949
|
+
# v2 resolution: Chat pin > Agent model > platform default, model_policy
|
|
1657
1950
|
# enforced, fallback chain resolved. Kept on the state for telemetry (usage).
|
|
1658
1951
|
selection = @model_resolver.resolve(profile: profile, session: state.session)
|
|
1659
1952
|
state.model_selection = selection
|
|
1953
|
+
build_chat(selection, selection)
|
|
1954
|
+
end
|
|
1955
|
+
|
|
1956
|
+
# The gem boundary: one chat for a model selection (the resolved primary or
|
|
1957
|
+
# a WS3 fallback node). The primary's generation params apply to the whole
|
|
1958
|
+
# chain (params_source: ModelSelection#apply_params).
|
|
1959
|
+
def build_chat(selection, params_source)
|
|
1960
|
+
model = selection.respond_to?(:model) ? selection.model : selection[:model]
|
|
1961
|
+
provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
|
|
1660
1962
|
chat = (@llm || RubyLLM).chat(
|
|
1661
|
-
model:
|
|
1662
|
-
provider:
|
|
1663
|
-
assume_model_exists:
|
|
1963
|
+
model: model,
|
|
1964
|
+
provider: provider,
|
|
1965
|
+
assume_model_exists: !provider.nil?
|
|
1664
1966
|
)
|
|
1665
|
-
|
|
1967
|
+
params_source.apply_params(chat) # temperature/max_tokens/thinking (per-agent)
|
|
1666
1968
|
chat
|
|
1667
1969
|
end
|
|
1668
1970
|
|
|
1669
1971
|
# Single emitter: an Event with meta and a monotonic seq per task. @seqs is not
|
|
1670
1972
|
# cleared at the end of the task — the resume (new Execution) continues the
|
|
1671
|
-
# numbering (reliable replay).
|
|
1973
|
+
# numbering (reliable replay). A task WITH a tenant (WS1) tags every event it
|
|
1974
|
+
# emits — the tenant-scoped /v1/events subscription filters on it (a control
|
|
1975
|
+
# event without a task has no tenant and never matches a tenant stream);
|
|
1976
|
+
# absent tenant -> the meta is byte-identical to before.
|
|
1672
1977
|
def emit(type, data, task:)
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1978
|
+
meta = { task_id: task.id, session_id: task.session_id,
|
|
1979
|
+
seq: (@seqs[task.id] += 1), at: Time.now.utc.iso8601 }
|
|
1980
|
+
tenant = task_tenant(task)
|
|
1981
|
+
meta[:tenant] = tenant unless tenant.nil?
|
|
1982
|
+
@event_stream.emit(Insika::Event.new(type: type, data: data, meta: meta))
|
|
1983
|
+
end
|
|
1984
|
+
|
|
1985
|
+
# The tenant stamped on the task's command (WS1), nil when the request was
|
|
1986
|
+
# operator-made. Cheap read on the persisted command hash — never rebuilds.
|
|
1987
|
+
def task_tenant(task)
|
|
1988
|
+
command = task.respond_to?(:command) ? task.command : nil
|
|
1989
|
+
return nil unless command.is_a?(Hash)
|
|
1990
|
+
|
|
1991
|
+
meta = command["meta"] || command[:meta] || {}
|
|
1992
|
+
meta["tenant"] || meta[:tenant]
|
|
1678
1993
|
end
|
|
1679
1994
|
end
|
|
1680
1995
|
end
|