insika 0.1.0 → 0.3.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 +199 -5
- data/README.md +8 -2
- data/bin/insika +231 -13
- data/docs/AGENTS.md +505 -6
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +100 -10
- data/docs/CONTEXT.md +147 -19
- data/docs/DEPLOY.md +34 -11
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +20 -1
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +17 -10
- data/docs/OBSERVABILITY.md +65 -2
- data/docs/REFINEMENT.md +9 -9
- data/docs/RELEASING.md +34 -7
- data/docs/RUNNING-LOCAL.md +4 -4
- data/docs/SECURITY.md +85 -11
- data/docs/SKILLS.md +189 -3
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +2 -2
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +228 -26
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +166 -0
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +132 -24
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +80 -6
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +9 -9
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +145 -13
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +6 -4
- data/lib/insika/commands/cancel_followup.rb +49 -0
- 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/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +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/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +112 -15
- data/lib/insika/commands/session_purge.rb +67 -0
- 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/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +23 -5
- data/lib/insika/context/fragment.rb +31 -3
- data/lib/insika/context/priority.rb +6 -2
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- 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 +7 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +128 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +962 -7
- data/lib/insika/dsl/runtime.rb +20 -11
- data/lib/insika/dsl/server_boot.rb +74 -4
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +152 -15
- data/lib/insika/edge_limiter.rb +167 -8
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +22 -12
- data/lib/insika/errors.rb +72 -5
- data/lib/insika/evals/assertions.rb +15 -14
- 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 +21 -9
- 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/evidence.rb +183 -0
- data/lib/insika/executor.rb +1092 -160
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +3 -3
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/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/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +8 -3
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +8 -3
- data/lib/insika/outbox_store.rb +44 -6
- data/lib/insika/outcome_store.rb +147 -0
- 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/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- 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/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +34 -7
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +6 -3
- 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 +10 -10
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +211 -0
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +46 -6
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +34 -115
- data/lib/insika/safety/factory.rb +18 -5
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +20 -5
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +10 -6
- data/lib/insika/safety/output_validator.rb +13 -7
- 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/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +366 -54
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +31 -7
- data/lib/insika/server/responses.rb +58 -9
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +11 -7
- data/lib/insika/session_store.rb +66 -3
- data/lib/insika/settings_store.rb +15 -5
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +131 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +11 -2
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1058 -75
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +27 -26
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +274 -22
- data/lib/insika/studio/nav_icons.rb +7 -2
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +629 -86
- data/lib/insika/studio/views/agents.erb +11 -7
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +133 -3
- data/lib/insika/studio/views/settings.erb +9 -12
- data/lib/insika/studio/views/skills.erb +66 -12
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +22 -2
- 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 +54 -33
- data/lib/insika/tick.rb +146 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +25 -15
- data/lib/insika/tool_envelope.rb +70 -1
- data/lib/insika/tool_manifest.rb +11 -7
- 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 +14 -5
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/schedule_followup.rb +164 -0
- 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/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +54 -13
- data/lib/insika/turn_timing.rb +24 -4
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +372 -34
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +122 -16
- metadata +95 -2
- data/lib/insika/server/admin_auth.rb +0 -29
data/lib/insika/executor.rb
CHANGED
|
@@ -19,7 +19,10 @@ 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, media: nil, media_output: nil,
|
|
24
|
+
grounding_enforcer: nil, cache_series_store: nil,
|
|
25
|
+
contact_store: nil, followup_store: nil, model_visible_trace_store: nil)
|
|
23
26
|
@context_builder = context_builder
|
|
24
27
|
@policy_engine = policy_engine
|
|
25
28
|
@middleware = middleware
|
|
@@ -36,31 +39,63 @@ module Insika
|
|
|
36
39
|
@pending_action_store = pending_action_store # approval gate
|
|
37
40
|
@capability_registry = capability_registry # capability resolution (nil = off)
|
|
38
41
|
@tool_trace_store = tool_trace_store # tool-call trace for Studio debugging (nil = off)
|
|
39
|
-
#
|
|
42
|
+
# per-turn context breakdown (tokens by category + budget) for the
|
|
43
|
+
# Studio session card. nil = off (no record, zero overhead — parity).
|
|
44
|
+
@context_trace_store = context_trace_store
|
|
45
|
+
# the model-visible trace — what the provider received per
|
|
46
|
+
# (task, turn), captured at the chat boundary. nil = off (no record,
|
|
47
|
+
# zero overhead — parity).
|
|
48
|
+
@model_visible_trace_store = model_visible_trace_store
|
|
49
|
+
# Guardrails output filter: ->(state) { OutputFilter | nil }.
|
|
40
50
|
# Injected by the Safety::Factory; nil = off (parity — the stream is untouched).
|
|
41
51
|
# The INPUT guardrail is a Middleware (in the stack, not here); this is the seam
|
|
42
52
|
# for the stream-side redaction the Executor owns.
|
|
43
53
|
@content_filter_factory = content_filter_factory
|
|
44
|
-
#
|
|
54
|
+
# durable record of ASYNC delegations. nil = async
|
|
45
55
|
# delegation OFF (only the synchronous spawn_subagent works — parity). When
|
|
46
56
|
# present, run_subagent(async: true) dispatches + returns immediately and the
|
|
47
57
|
# child's result is delivered to the parent session as a NEW turn on completion.
|
|
48
58
|
@delegation_store = delegation_store
|
|
49
|
-
#
|
|
59
|
+
# outbound delivery for Shape B channels. nil = no channel
|
|
50
60
|
# delivers out of band (parity — every surface today answers on the request's
|
|
51
61
|
# own connection). When present, a turn that CAME IN through a channel writes
|
|
52
62
|
# its answer to the outbox at the terminal and the dispatcher POSTs it.
|
|
53
63
|
@channel_delivery = channel_delivery
|
|
54
|
-
#
|
|
64
|
+
# the chat FACTORY this executor asks — a RubyLLM::Context (an
|
|
55
65
|
# isolated config dup) when the graph owns its credentials, nil = the
|
|
56
66
|
# process-wide RubyLLM constant (the historic single-graph deployment).
|
|
57
67
|
# Duck-typed: Context#chat and RubyLLM.chat take the same keywords.
|
|
58
68
|
@llm = llm
|
|
59
|
-
#
|
|
69
|
+
# stability for the turn's single agent interaction (WS3): retries /
|
|
70
|
+
# fallback / circuit breaker, all DATA on AgentProfile#reliability.
|
|
71
|
+
# nil = the plain single ask (parity).
|
|
72
|
+
@reliability = reliability
|
|
73
|
+
# WS9 media seam (nil = default built on first audio turn): ->(url) { text }.
|
|
74
|
+
@media = media
|
|
75
|
+
# WS9 (saída) media-generation seams: { image: ->(prompt, cfg) [part,
|
|
76
|
+
# usage], tts: ->(text, cfg) [part, usage] }. nil = the defaults (built
|
|
77
|
+
# lazily on first generation — RubyLLM + Net::HTTP behind lazy requires,
|
|
78
|
+
# the core stays gem-free at load). Injected by specs; a production graph
|
|
79
|
+
# that wants a non-RubyLLM backend injects its own lambdas.
|
|
80
|
+
@media_output = media_output
|
|
81
|
+
# the :enforce boundary step, called between stages 6 and 8.
|
|
82
|
+
# Defaults to a REAL enforcer (inert unless the profile's grounding.mode is
|
|
83
|
+
# :enforce — zero behavior change for parity) so an embedder that builds
|
|
84
|
+
# the Executor directly still gets the cut; `nil` stays injectable for
|
|
85
|
+
# stubs that want none.
|
|
86
|
+
@grounding_enforcer = grounding_enforcer || Insika::Safety::GroundingEnforcer.new
|
|
87
|
+
# the per-AGENT cache-hit series. nil = no series recorded
|
|
88
|
+
# (parity — the trace store still gets the per-turn entry when wired).
|
|
89
|
+
@cache_series_store = cache_series_store
|
|
90
|
+
# LLM config v2: resolves the model at turn start (Chat > Agent >
|
|
60
91
|
# platform default) + model_policy + fallback chain. settings_store nil =
|
|
61
92
|
# no platform layer (pre-v2 behavior: the agent's own model is used as-is).
|
|
62
93
|
@model_resolver = ModelResolver.new(settings_store: settings_store)
|
|
63
|
-
#
|
|
94
|
+
# the follow-up stores the ChatBuilder gates the
|
|
95
|
+
# schedule/cancel_followup tools on (nil = never wired — parity).
|
|
96
|
+
@contact_store = contact_store
|
|
97
|
+
@followup_store = followup_store
|
|
98
|
+
# the platform layer of the queue policy (nil = per-agent and
|
|
64
99
|
# defaults only, which is `followup` with no window — today's behavior).
|
|
65
100
|
@settings_store = settings_store
|
|
66
101
|
# RubyLLM glue (stages 5-7): chat assembly delegated to ChatBuilder. Its
|
|
@@ -71,13 +106,27 @@ module Insika
|
|
|
71
106
|
tool_registry: tool_registry, skill_catalog: skill_catalog,
|
|
72
107
|
checkpoint_store: checkpoint_store, event_stream: event_stream, hooks: hooks,
|
|
73
108
|
tool_catalog: tool_catalog, memory_store: memory_store,
|
|
74
|
-
#
|
|
109
|
+
# load_skill is not enveloped, so it records its own trace entry.
|
|
110
|
+
tool_trace_store: tool_trace_store,
|
|
111
|
+
# the ChatBuilder wires the spawn_subagent system tool (gated by
|
|
75
112
|
# profile.subagents) and hands it this Executor as the runner. `self` is not
|
|
76
113
|
# yet fully built here, but the ChatBuilder only STORES it (used per-turn).
|
|
77
|
-
subagent_runner: self
|
|
114
|
+
subagent_runner: self,
|
|
115
|
+
# WS9 (saída): the media-generation runner, same shape — the Executor
|
|
116
|
+
# owns the seams + usage accounting, the builder only wires the tools
|
|
117
|
+
# the turn's gates allow.
|
|
118
|
+
media_runner: self,
|
|
119
|
+
# the builder wires the briefing-write system tools gated by
|
|
120
|
+
# @session_store + profile.briefing_fields. nil = never wired (parity).
|
|
121
|
+
session_store: session_store,
|
|
122
|
+
# the builder wires the schedule/cancel_followup system
|
|
123
|
+
# tools gated by a parsed policy AND both stores present. nil = never
|
|
124
|
+
# wired (parity).
|
|
125
|
+
contact_store: contact_store,
|
|
126
|
+
followup_store: followup_store
|
|
78
127
|
)
|
|
79
|
-
# Stage-3-tail tool assembly (capability resolution, instantiation,
|
|
80
|
-
# injection, dedup join, ToolEnvelope wrap) — extracted collaborator
|
|
128
|
+
# Stage-3-tail tool assembly (capability resolution, instantiation,
|
|
129
|
+
# injection, dedup join, ToolEnvelope wrap) — extracted collaborator.
|
|
81
130
|
@tool_assembly = ToolAssembly.new(
|
|
82
131
|
tool_registry: tool_registry, capability_registry: capability_registry,
|
|
83
132
|
event_stream: event_stream, checkpoint_store: checkpoint_store,
|
|
@@ -88,7 +137,7 @@ module Insika
|
|
|
88
137
|
@supervised = false # serving mode? — see #turn_parent
|
|
89
138
|
@supervisor = nil # lazy long-lived supervisor (created when serving)
|
|
90
139
|
@session_actors = {} # session_id => SessionActor (FIFO queue)
|
|
91
|
-
@draining = false # shutdown drain
|
|
140
|
+
@draining = false # shutdown drain — see #begin_drain!
|
|
92
141
|
end
|
|
93
142
|
|
|
94
143
|
# Turns on SERVING mode: the composition root's serving arm (serve.rb /
|
|
@@ -102,7 +151,31 @@ module Insika
|
|
|
102
151
|
# concurrency).
|
|
103
152
|
attr_accessor :supervised
|
|
104
153
|
|
|
105
|
-
#
|
|
154
|
+
# the periodic tick (outbox drain + stale recovery sweep), wired
|
|
155
|
+
# by the graph AFTER the bus exists (the tick's recovery half dispatches
|
|
156
|
+
# through it). nil = no tick (parity — recovery stays boot-only). When
|
|
157
|
+
# present and serving, it starts as a child of the turn supervisor (see
|
|
158
|
+
# #turn_parent).
|
|
159
|
+
attr_accessor :tick
|
|
160
|
+
|
|
161
|
+
# The WS6 alert dispatcher: answers budget_warning / breaker_open /
|
|
162
|
+
# delivery_failed with a durable webhook delivery. Started as a child of
|
|
163
|
+
# the turn supervisor in serving mode (like the tick); nil = no alerts.
|
|
164
|
+
attr_accessor :alert_dispatcher
|
|
165
|
+
|
|
166
|
+
# the distillation engine — the tick-duty that finds idle
|
|
167
|
+
# customer sessions and distills them on its own worker fiber (a child of
|
|
168
|
+
# the turn supervisor, like the tick). nil = distillation off (parity —
|
|
169
|
+
# nothing scans, nothing distills).
|
|
170
|
+
attr_accessor :distill_engine
|
|
171
|
+
|
|
172
|
+
# the harvest engine — the tick-duty that finds idle,
|
|
173
|
+
# unmined sessions and mines them on its own worker fiber (a child of the
|
|
174
|
+
# turn supervisor, like the tick). nil = harvest off (parity — nothing
|
|
175
|
+
# scans, nothing mines).
|
|
176
|
+
attr_accessor :harvest_engine
|
|
177
|
+
|
|
178
|
+
# closes the TURN intake for shutdown. Armed by Insika::Shutdown
|
|
106
179
|
# when the process is asked to stop: from here on a new top-level turn is left
|
|
107
180
|
# `:queued` (durable — the next boot's recovery replays it) instead of
|
|
108
181
|
# spawning, while the in-flight turns run to their natural end. One-way by
|
|
@@ -203,12 +276,17 @@ module Insika
|
|
|
203
276
|
|
|
204
277
|
# Stage 1 (async part): creates the actor, registers it and fires the fiber.
|
|
205
278
|
# Called by the turn handlers (SendMessage/ResumeTask/TriggerWorkflow).
|
|
206
|
-
|
|
279
|
+
#
|
|
280
|
+
# `timing` is the channel clock a channel turn allocated at 202
|
|
281
|
+
# acceptance, already carrying `:inbound`; nil means the pipeline allocates its
|
|
282
|
+
# own (resume, engine-initiated, non-channel). `mark` is first-write-wins, so
|
|
283
|
+
# re-marking `:inbound` in the pipeline is a no-op on a threaded clock.
|
|
284
|
+
def spawn(task, profile:, resume_from: nil, timing: nil)
|
|
207
285
|
raise Insika::ValidationError, "task already running: #{task.id}" if running?(task.id)
|
|
208
286
|
|
|
209
287
|
actor = TaskActor.new(task_id: task.id, parent: turn_parent)
|
|
210
288
|
@running[task.id] = actor
|
|
211
|
-
actor.run { execute(task, profile: profile, resume_from: resume_from, actor: actor) }
|
|
289
|
+
actor.run { execute(task, profile: profile, resume_from: resume_from, actor: actor, timing: timing) }
|
|
212
290
|
task.id
|
|
213
291
|
end
|
|
214
292
|
|
|
@@ -216,8 +294,8 @@ module Insika
|
|
|
216
294
|
# session_id is SERIALIZED in that session's SessionActor queue (one at a
|
|
217
295
|
# time); without a session_id (one-shot/history) it goes straight to spawn
|
|
218
296
|
# (standalone).
|
|
219
|
-
def spawn_in_session(task, profile:, resume_from: nil)
|
|
220
|
-
#
|
|
297
|
+
def spawn_in_session(task, profile:, resume_from: nil, timing: nil)
|
|
298
|
+
# the intake is closed. The task is already durable (queued);
|
|
221
299
|
# answering with its id and spawning NOTHING is what "stops accepting new
|
|
222
300
|
# turns" means — the next boot's recovery replays it. Subagent turns are NOT
|
|
223
301
|
# gated (they spawn directly): a child of an in-flight parent is part of the
|
|
@@ -230,14 +308,15 @@ module Insika
|
|
|
230
308
|
# owner awaits the turn). One-shot/history (no session_id)
|
|
231
309
|
# never serialize.
|
|
232
310
|
unless @supervised && task.session_id
|
|
233
|
-
return spawn(task, profile: profile, resume_from: resume_from)
|
|
311
|
+
return spawn(task, profile: profile, resume_from: resume_from, timing: timing)
|
|
234
312
|
end
|
|
235
313
|
|
|
236
314
|
session_actor(task.session_id).enqueue(task, profile: profile, resume_from: resume_from,
|
|
237
|
-
policy: queue_policy(profile, task.session_id)
|
|
315
|
+
policy: queue_policy(profile, task.session_id),
|
|
316
|
+
timing: timing)
|
|
238
317
|
end
|
|
239
318
|
|
|
240
|
-
#
|
|
319
|
+
# the `collect` door, asked BEFORE a task is created.
|
|
241
320
|
# -> the task id the fragment joined, or nil (create a task and spawn as usual).
|
|
242
321
|
#
|
|
243
322
|
# Asking first is what keeps the store clean: creating a task and then
|
|
@@ -255,7 +334,7 @@ module Insika
|
|
|
255
334
|
actor.collect(text)
|
|
256
335
|
end
|
|
257
336
|
|
|
258
|
-
#
|
|
337
|
+
# the `steer` door: a message for a session whose turn is ALREADY
|
|
259
338
|
# running is appended to that run instead of becoming a turn of its own.
|
|
260
339
|
# -> the RUNNING task's id (the turn that will answer it), or nil (create a task and
|
|
261
340
|
# spawn as usual, which is `followup`).
|
|
@@ -274,7 +353,8 @@ module Insika
|
|
|
274
353
|
return nil unless session_actor&.alive?
|
|
275
354
|
|
|
276
355
|
# No turn running (or one still at the door): there is nothing to steer INTO.
|
|
277
|
-
# A turn at the door
|
|
356
|
+
# A turn at the door is the collect door's other window — a steer agent with a
|
|
357
|
+
# debounce merges there instead , so no message waits on either.
|
|
278
358
|
task = session_actor.current_task
|
|
279
359
|
return nil if task.nil?
|
|
280
360
|
# A workflow turn orchestrates RubyLLM itself and has no Insika chat to append to
|
|
@@ -293,7 +373,7 @@ module Insika
|
|
|
293
373
|
task.id
|
|
294
374
|
end
|
|
295
375
|
|
|
296
|
-
#
|
|
376
|
+
# the `interrupt` door: the turn in flight is answering a question
|
|
297
377
|
# the customer has already replaced, so it is abandoned and the new message becomes an
|
|
298
378
|
# ordinary turn. -> the abandoned task's id, or nil (nothing was running).
|
|
299
379
|
#
|
|
@@ -305,7 +385,7 @@ module Insika
|
|
|
305
385
|
# `:cancel` is observed only at a stage boundary, so a tool call in flight runs to
|
|
306
386
|
# completion and is recorded. Cancelling the not-yet-started calls of a batch would
|
|
307
387
|
# leave it half applied, and fabricating failure results would teach the model that
|
|
308
|
-
# tools failed when they did not (
|
|
388
|
+
# tools failed when they did not (records the same boundary for `turn_timeout`).
|
|
309
389
|
def interrupt_running(session_id, profile:, replaced_by: nil)
|
|
310
390
|
return nil unless @supervised && session_id
|
|
311
391
|
|
|
@@ -329,7 +409,7 @@ module Insika
|
|
|
329
409
|
# its own, by design — it owns scheduling, not persistence).
|
|
330
410
|
attr_reader :task_store
|
|
331
411
|
|
|
332
|
-
#
|
|
412
|
+
# Emitted by the SessionActor when a window closes having merged
|
|
333
413
|
# more than one fragment. `arrivals` are the ISO8601 times each fragment landed
|
|
334
414
|
# — the ONLY record that they were separate messages, since a merged fragment
|
|
335
415
|
# creates no task of its own. Ids and times, never content.
|
|
@@ -337,7 +417,7 @@ module Insika
|
|
|
337
417
|
emit(:turn_coalesced, { task_id: task.id, merged: merged, arrivals: arrivals }, task: task)
|
|
338
418
|
end
|
|
339
419
|
|
|
340
|
-
#
|
|
420
|
+
# a steered message the run could NOT absorb: no tool batch ever
|
|
341
421
|
# closed (a text-only turn), the batch ended in `halt_when`, the turn failed, or it
|
|
342
422
|
# was cancelled. The message is a person's and must not evaporate, so it is released
|
|
343
423
|
# as the next turn on this session — which is `followup`, arrived at late.
|
|
@@ -382,13 +462,13 @@ module Insika
|
|
|
382
462
|
# its completion before returning — that is what serializes the session. A
|
|
383
463
|
# turn error is already mapped to a terminal state inside its own fiber (single
|
|
384
464
|
# capture); here we only ensure the session loop does not die.
|
|
385
|
-
def run_serial(task, profile:, resume_from: nil)
|
|
386
|
-
#
|
|
465
|
+
def run_serial(task, profile:, resume_from: nil, timing: nil)
|
|
466
|
+
# a drain that started with turns already queued behind this
|
|
387
467
|
# session's current one must not keep feeding the loop — without this gate
|
|
388
468
|
# the drain would only converge when the whole backlog ran out.
|
|
389
469
|
return defer_turn(task) if @draining
|
|
390
470
|
|
|
391
|
-
spawn(task, profile: profile, resume_from: resume_from)
|
|
471
|
+
spawn(task, profile: profile, resume_from: resume_from, timing: timing)
|
|
392
472
|
@running[task.id]&.wait
|
|
393
473
|
rescue Async::Stop
|
|
394
474
|
raise # shutdown: propagate (ends the session loop)
|
|
@@ -408,15 +488,15 @@ module Insika
|
|
|
408
488
|
nil
|
|
409
489
|
end
|
|
410
490
|
|
|
411
|
-
#
|
|
491
|
+
# runs a CHILD agent turn (called by Tools::Subagent during
|
|
412
492
|
# stage 6). Isolated context (fresh child session), capability NON-inheritance
|
|
413
493
|
# (child profile resolved fresh), environment inheritance (model/thinking seeded
|
|
414
494
|
# from the parent). NEVER raises: a bad agent/depth/child failure is a message
|
|
415
495
|
# to the model, not a turn-killer.
|
|
416
496
|
#
|
|
417
|
-
# async:false (default
|
|
497
|
+
# async:false (default) — SYNCHRONOUS: runs the child inside the parent's
|
|
418
498
|
# fiber and returns { text:, session_id: } (the child result is the tool result).
|
|
419
|
-
# async:true
|
|
499
|
+
# async:true — DURABLE dispatch: spawns the child NON-blocking, persists
|
|
420
500
|
# a Delegation, returns { dispatched:, agent:, session_id: } immediately; the
|
|
421
501
|
# parent turn ends and the child's result is later delivered as a NEW turn on
|
|
422
502
|
# the parent session (needs a delegation_store — else falls back to sync).
|
|
@@ -431,7 +511,7 @@ module Insika
|
|
|
431
511
|
end
|
|
432
512
|
end
|
|
433
513
|
|
|
434
|
-
#
|
|
514
|
+
# (fan-out): runs SEVERAL child turns IN PARALLEL and returns all
|
|
435
515
|
# results together, in the requested order. This is the real latency win — the
|
|
436
516
|
# children overlap their provider waits on the reactor, so wall-clock ≈ the
|
|
437
517
|
# slowest child, not the sum. Always sync-join (a combined result in the parent's
|
|
@@ -449,7 +529,7 @@ module Insika
|
|
|
449
529
|
{ results: spawn_all_and_project(plans, parent_state) }
|
|
450
530
|
end
|
|
451
531
|
|
|
452
|
-
#
|
|
532
|
+
# (boot): reconciles ASYNC delegations after a crash so a
|
|
453
533
|
# completed child's result is never lost. For each undelivered Delegation:
|
|
454
534
|
# · child TERMINAL, not captured -> capture + deliver.
|
|
455
535
|
# · child TERMINAL, captured (completed) -> deliver (crash before delivery).
|
|
@@ -471,7 +551,7 @@ module Insika
|
|
|
471
551
|
{ delivered: delivered }
|
|
472
552
|
end
|
|
473
553
|
|
|
474
|
-
#
|
|
554
|
+
# (boot): re-drives the outbound replies a previous process
|
|
475
555
|
# recorded and never claimed. Records left `delivering` are NOT swept — that
|
|
476
556
|
# process may have POSTed before it died, and re-sending is the duplicate the
|
|
477
557
|
# claim exists to prevent. No-op without a channel_delivery.
|
|
@@ -483,7 +563,7 @@ module Insika
|
|
|
483
563
|
end
|
|
484
564
|
|
|
485
565
|
# Stages 2..9. Runs INSIDE the task's fiber.
|
|
486
|
-
def execute(task, profile:, actor:, resume_from: nil)
|
|
566
|
+
def execute(task, profile:, actor:, resume_from: nil, timing: nil)
|
|
487
567
|
# Resume of a crash orphan: the interrupted attempt's Execution was left OPEN
|
|
488
568
|
# (the fiber died). The TaskStore forbids opening a second one while one is
|
|
489
569
|
# open -> close the orphan as :interrupted before opening the N+1 (a new
|
|
@@ -497,7 +577,7 @@ module Insika
|
|
|
497
577
|
emit(:task_started, started_data(task, profile), task: task)
|
|
498
578
|
|
|
499
579
|
actor.drain!
|
|
500
|
-
run_pipeline(task, profile, actor, resume_from)
|
|
580
|
+
run_pipeline(task, profile, actor, resume_from, timing)
|
|
501
581
|
# SINGLE capture at the top of the fiber: a single place maps
|
|
502
582
|
# error -> terminal state -> events. Stages do no rescue of their own
|
|
503
583
|
# (except tool, RubyLLM semantics). The fiber NEVER re-raises.
|
|
@@ -510,8 +590,25 @@ module Insika
|
|
|
510
590
|
rescue PolicyDenied => e
|
|
511
591
|
emit(:policy_denied, { policy: e.policy, reason: e.reason }, task: task)
|
|
512
592
|
fail_task(task, e, stage: :policy)
|
|
593
|
+
rescue BudgetExceeded => e
|
|
594
|
+
# WS2 hard budget: a typed, retryable failure — the envelope reads
|
|
595
|
+
# budget_exceeded + retry_after (window roll), never a silent drop.
|
|
596
|
+
fail_task(task, e, stage: :budget)
|
|
597
|
+
rescue CircuitOpenError => e
|
|
598
|
+
# WS3 breaker: the turn died BEFORE the provider call — the envelope
|
|
599
|
+
# reads circuit_open + retry_after (cooldown remaining). Worth its own
|
|
600
|
+
# stage: an open breaker is a reliability decision, not an error bug.
|
|
601
|
+
fail_task(task, e, stage: :reliability)
|
|
602
|
+
rescue Insika::RoutingError => e
|
|
603
|
+
# WS4: a route's delegate is missing or its turn failed — an operator
|
|
604
|
+
# config error, staged so the envelope names routing, never :unknown.
|
|
605
|
+
fail_task(task, e, stage: :routing)
|
|
606
|
+
rescue Insika::MediaError => e
|
|
607
|
+
# WS9: a voice message that could not be fetched/transcribed (or a media
|
|
608
|
+
# URL the egress guard refused) — heard-loud, never a silent drop.
|
|
609
|
+
fail_task(task, e, stage: :media)
|
|
513
610
|
rescue Insika::WorkflowSchemaError => e
|
|
514
|
-
#
|
|
611
|
+
# a workflow OUTPUT that violates its output_schema. Distinct
|
|
515
612
|
# stage so a contract breach is not conflated with an :unknown failure. (INPUT
|
|
516
613
|
# is validated synchronously in TriggerWorkflow -> 422, never reaches here.)
|
|
517
614
|
fail_task(task, e, stage: :workflow_schema)
|
|
@@ -526,7 +623,15 @@ module Insika
|
|
|
526
623
|
rescue TimeoutError => e
|
|
527
624
|
fail_task(task, e, stage: e.stage)
|
|
528
625
|
rescue StandardError => e
|
|
529
|
-
|
|
626
|
+
# A provider/transport failure is NOT an :unknown bug: wrap it with its
|
|
627
|
+
# action classification (B9) so the envelope can quote retryable and the
|
|
628
|
+
# provider's own retry_after (A8). The classifier is class-name based —
|
|
629
|
+
# the :ruby_llm stage stays reachable even under the smoke-shim's fake.
|
|
630
|
+
if ProviderErrorClassifier.provider_error?(e)
|
|
631
|
+
fail_task(task, ProviderErrorClassifier.wrap(e), stage: :ruby_llm)
|
|
632
|
+
else
|
|
633
|
+
fail_task(task, e, stage: :unknown)
|
|
634
|
+
end
|
|
530
635
|
ensure
|
|
531
636
|
@running.delete(task.id) # ALWAYS deregister (a false-positive running? would break the resume)
|
|
532
637
|
# Deregistered FIRST on purpose: from here on the `steer` door finds no actor for
|
|
@@ -535,9 +640,39 @@ module Insika
|
|
|
535
640
|
release_steered(task, profile, actor)
|
|
536
641
|
end
|
|
537
642
|
|
|
643
|
+
# WS9 (saída), the RUNNER side the generate_image/tts system tools call
|
|
644
|
+
# (public like run_subagent — a tool reaches back into the Executor):
|
|
645
|
+
#
|
|
646
|
+
# -> [part, usage]: resolve the seam (injected or the lazy default) and
|
|
647
|
+
# run it. The default seams are built on FIRST use, when the turn already
|
|
648
|
+
# has a chat (ruby_llm loaded), so the load-guard holds.
|
|
649
|
+
def generate_media_output(kind, content, config)
|
|
650
|
+
seam = @media_output&.fetch(kind, nil) || Insika::Media::Output.defaults(context: @llm)[kind]
|
|
651
|
+
raise Insika::MediaError, "no #{kind} output seam" unless seam
|
|
652
|
+
|
|
653
|
+
seam.call(content, config)
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# Accounts a generated part in the turn's usage: the provider's token
|
|
657
|
+
# counts (images — the merge keeps what the classifier already banked),
|
|
658
|
+
# plus an honest `media` call counter per part (the speech API reports no
|
|
659
|
+
# tokens; the part itself carries the model for consumer-side pricing).
|
|
660
|
+
def account_media_usage(state, part, usage)
|
|
661
|
+
usage ||= {}
|
|
662
|
+
tokens = {}
|
|
663
|
+
tokens[:input_tokens] = usage[:input_tokens].to_i if usage[:input_tokens]
|
|
664
|
+
tokens[:output_tokens] = usage[:output_tokens].to_i if usage[:output_tokens]
|
|
665
|
+
tokens[:total_tokens] = tokens[:input_tokens].to_i + tokens[:output_tokens].to_i if tokens.any?
|
|
666
|
+
unless tokens.empty?
|
|
667
|
+
tokens[:model] = part["model"] if part["model"]
|
|
668
|
+
state.usage = merge_usage(tokens, state.usage)
|
|
669
|
+
end
|
|
670
|
+
state.usage = (state.usage || {}).merge(media: state.usage&.fetch(:media, 0).to_i + 1)
|
|
671
|
+
end
|
|
672
|
+
|
|
538
673
|
private
|
|
539
674
|
|
|
540
|
-
#
|
|
675
|
+
# resolves session vars > profile.limits > settings["queue"] >
|
|
541
676
|
# defaults. One session read plus one settings read per message, the same
|
|
542
677
|
# order of cost the EdgeLimiter already pays per turn. A missing session (or
|
|
543
678
|
# no session store) simply drops the vars layer.
|
|
@@ -558,7 +693,7 @@ module Insika
|
|
|
558
693
|
SessionActor.new(session_id: session_id, executor: self, parent: turn_parent)
|
|
559
694
|
end
|
|
560
695
|
|
|
561
|
-
#
|
|
696
|
+
# a turn that arrived while the process is draining: left
|
|
562
697
|
# `:queued` on purpose (recovery replays :queued at the next boot). The event
|
|
563
698
|
# is the deferral's only trace — without it, "why was my message answered
|
|
564
699
|
# only after the deploy" is unanswerable.
|
|
@@ -597,9 +732,23 @@ module Insika
|
|
|
597
732
|
# 2.42): blocks on a dequeue that never arrives. Ends only when the scope is
|
|
598
733
|
# stopped (server shutdown) — then any child turns still running go with it.
|
|
599
734
|
# In a deployment that is the LAST resort, not the plan: Insika::Shutdown
|
|
600
|
-
# drains first
|
|
735
|
+
# drains first, so only what outlives the drain deadline dies
|
|
601
736
|
# here, `:running`, for the next boot's recovery to replay.
|
|
602
737
|
@supervisor = node.async { |t| t.annotate("harness-turn-supervisor"); Async::Queue.new.dequeue }
|
|
738
|
+
# the periodic tick is a child of the supervisor — it binds to
|
|
739
|
+
# the serving reactor in every arm with no arm edits, and dies with the
|
|
740
|
+
# supervisor at shutdown (after Shutdown's drain, like any turn).
|
|
741
|
+
@tick&.start(parent: @supervisor)
|
|
742
|
+
# the alert dispatcher (WS6) lives on the same supervisor: its consumer
|
|
743
|
+
# answers every alert event for as long as the process serves.
|
|
744
|
+
@alert_dispatcher&.start(parent: @supervisor)
|
|
745
|
+
# the distillation engine lives on the same supervisor —
|
|
746
|
+
# its worker fiber re-scans idle sessions off the turn path.
|
|
747
|
+
@distill_engine&.start(parent: @supervisor)
|
|
748
|
+
# the harvest engine lives on the same supervisor — its
|
|
749
|
+
# worker fiber re-scans idle sessions off the turn path.
|
|
750
|
+
@harvest_engine&.start(parent: @supervisor)
|
|
751
|
+
@supervisor
|
|
603
752
|
end
|
|
604
753
|
|
|
605
754
|
# Deterministic PendingAction id: correlation by task+turn+tool.
|
|
@@ -608,7 +757,7 @@ module Insika
|
|
|
608
757
|
# checkpointing is a future slice. One call per tool is safe.
|
|
609
758
|
def pending_id(task_id, turn, tool) = "#{task_id}:#{turn}:#{tool}"
|
|
610
759
|
|
|
611
|
-
#
|
|
760
|
+
# all readings of the persisted command go through rebuild_command
|
|
612
761
|
# (the single normalizer). command_type stays a STRING (the :task_started
|
|
613
762
|
# event and the Telemetry attribute are string-typed).
|
|
614
763
|
def command_type(task)
|
|
@@ -641,10 +790,17 @@ module Insika
|
|
|
641
790
|
return nil
|
|
642
791
|
end
|
|
643
792
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
793
|
+
# The task's error record and the :task_failed event both carry the
|
|
794
|
+
# classification when the failure is a wrapped ProviderError (B9/A8):
|
|
795
|
+
# additive fields, absent for every other error.
|
|
796
|
+
classification = error.respond_to?(:classification) ? error.classification : {}
|
|
797
|
+
spec = { class: error.class.name, message: error.message, stage: stage }
|
|
798
|
+
spec = spec.merge(classification) unless classification.empty?
|
|
799
|
+
@task_store.transition(task.id, to: :failed, error: spec)
|
|
800
|
+
data = { task_id: task.id, error: error.class.name, message: error.message }
|
|
801
|
+
data = data.merge(classification) unless classification.empty?
|
|
802
|
+
emit(:task_failed, data, task: task)
|
|
803
|
+
# a FAILED delegation child still delivers — the parent
|
|
648
804
|
# receives an error note as a new turn (never left hanging).
|
|
649
805
|
finalize_delegation(task)
|
|
650
806
|
nil
|
|
@@ -656,8 +812,23 @@ module Insika
|
|
|
656
812
|
# Stages 2-9, with mailbox drain only at the boundaries and the
|
|
657
813
|
# turn-timeout wrapping everything via Async::Task#with_timeout — NEVER
|
|
658
814
|
# stdlib Timeout.timeout.
|
|
659
|
-
def run_pipeline(task, profile, actor, resume_from)
|
|
660
|
-
|
|
815
|
+
def run_pipeline(task, profile, actor, resume_from, timing = nil)
|
|
816
|
+
# a CHANNEL turn always allocates the clock — first_balloon_ms
|
|
817
|
+
# (inbound -> first outbox flush) is H-latência and must not depend on
|
|
818
|
+
# INSIKA_TURN_TIMING. When the flag is off the clock measures ONLY that
|
|
819
|
+
# window (`breakdown: false`); the full prep/ttft/gen/total stays opt-in.
|
|
820
|
+
#
|
|
821
|
+
# A channel turn may already carry its clock: `SendMessage` stamped
|
|
822
|
+
# `:inbound` at 202 acceptance (before the debounce window and the
|
|
823
|
+
# SessionActor FIFO), so first_balloon_ms includes the wait the customer
|
|
824
|
+
# actually feels. A turn that reached here without one (boot resume,
|
|
825
|
+
# engine-initiated) falls back to allocating and stamps now — `mark` is
|
|
826
|
+
# first-write-wins, so a threaded clock is never re-stamped.
|
|
827
|
+
channel_turn = !channel_transport(task).nil?
|
|
828
|
+
timing ||= if TurnTiming.enabled? || channel_turn
|
|
829
|
+
TurnTiming.new(breakdown: TurnTiming.enabled?)
|
|
830
|
+
end
|
|
831
|
+
timing&.mark(:inbound) if channel_turn
|
|
661
832
|
timing&.mark(:prep_start)
|
|
662
833
|
state = build_turn_state(task, profile, resume_from)
|
|
663
834
|
turn_timeout = turn_timeout_for(profile)
|
|
@@ -682,12 +853,12 @@ module Insika
|
|
|
682
853
|
end
|
|
683
854
|
|
|
684
855
|
# A Middleware short-circuited (did not call the terminal). Three cases:
|
|
685
|
-
# · halt_response set -> GRACEFUL halt
|
|
856
|
+
# · halt_response set -> GRACEFUL halt: the turn COMPLETES
|
|
686
857
|
# with a safe reply, reusing stages 8-9, without ever touching the LLM.
|
|
687
858
|
# · halt_reason set -> halt-as-FAILURE (the pre-existing contract).
|
|
688
859
|
# · neither -> contract violation (short-circuit with no signal).
|
|
689
860
|
if !terminal_ran && state.halt_response
|
|
690
|
-
complete_with_halt(task, profile, state)
|
|
861
|
+
complete_with_halt(task, profile, state, timing)
|
|
691
862
|
elsif state.halt_reason
|
|
692
863
|
raise Insika::Error, "turn halted: #{state.halt_reason}"
|
|
693
864
|
elsif !terminal_ran
|
|
@@ -695,26 +866,41 @@ module Insika
|
|
|
695
866
|
end
|
|
696
867
|
|
|
697
868
|
state # subject of the :task pair (after_task receives it; the caller discards)
|
|
698
|
-
end.tap
|
|
869
|
+
end.tap do |st|
|
|
870
|
+
# flush the evidence ledger HERE — after the after_task
|
|
871
|
+
# hooks ran, because the :flag validator increments the ungrounded
|
|
872
|
+
# counter in after_task. The envelope's ids (stage 6) ride the same
|
|
873
|
+
# flush. AFTER persist_turn (already done), so a flush failure can
|
|
874
|
+
# never un-commit the turn (the ledger swallows store errors).
|
|
875
|
+
st.evidence_ledger&.flush!
|
|
876
|
+
emit_guardrail_flags(task, st)
|
|
877
|
+
end
|
|
699
878
|
end
|
|
700
879
|
rescue Async::TimeoutError
|
|
701
880
|
raise Insika::TimeoutError.new("turn exceeded #{turn_timeout}s", stage: :turn)
|
|
702
881
|
end
|
|
703
882
|
|
|
704
|
-
# Builds the turn's mutable state (turn number, message, memory tenant,
|
|
883
|
+
# Builds the turn's mutable state (turn number, message, memory tenant,
|
|
705
884
|
# turn context). before_task (hooks.around) may still rewrite it before stage 2.
|
|
706
885
|
def build_turn_state(task, profile, resume_from)
|
|
707
886
|
turn = resume_from ? resume_from.turn : 1
|
|
708
887
|
state = TurnState.new(task: task, profile: profile, turn: turn,
|
|
709
888
|
message: extract_message(task))
|
|
710
|
-
state.tenant = memory_tenant(task) # WRITE-path memory scope (`remember`); =chat
|
|
711
|
-
|
|
889
|
+
state.tenant = memory_tenant(task) # WRITE-path memory scope (`remember`); =chat
|
|
890
|
+
stamp_customer_session(task, profile)
|
|
891
|
+
state.turn_context = build_turn_context(task, profile, state) # data-tools' ctx.*
|
|
712
892
|
state.resumed = !resume_from.nil? # EdgeLimiter: an admitted turn is never re-counted
|
|
713
|
-
#
|
|
893
|
+
# resolved for the RUN, not per message, so what the turn accepts cannot
|
|
714
894
|
# change under it. Same cost as the EdgeLimiter's per-turn resolution. Only for a
|
|
715
895
|
# SESSION turn: steering needs a session to arrive through, and resolving here for a
|
|
716
896
|
# one-shot would make an unrelated turn fail on a queue key it can never use.
|
|
717
897
|
state.queue_policy = task.session_id ? queue_policy(profile, task.session_id) : nil
|
|
898
|
+
# the session evidence ledger, built per turn. A nil
|
|
899
|
+
# session_id (one-shot) is fine — the ledger just never flushes (grounding
|
|
900
|
+
# on a one-shot is per-turn by definition). The envelope appends ids, the
|
|
901
|
+
# validator/enforcer read the union, stage 8 flushes.
|
|
902
|
+
state.evidence_ledger = Insika::EvidenceLedger.new(store: @session_store,
|
|
903
|
+
session_id: task.session_id)
|
|
718
904
|
state
|
|
719
905
|
end
|
|
720
906
|
|
|
@@ -732,6 +918,17 @@ module Insika
|
|
|
732
918
|
# -> tool assembly, each followed by a mailbox drain at the boundary. Mutates
|
|
733
919
|
# `state` in place (the TurnState possibly rewritten by before_task).
|
|
734
920
|
def prepare_turn(task, profile, state, actor, resume_from)
|
|
921
|
+
# WS9 (saída): the CHANNEL's declared output media kinds — read on EVERY
|
|
922
|
+
# run (including resumes — it is plain config) so the ChatBuilder gate
|
|
923
|
+
# can re-wire the media tools; the transcription below stays guarded.
|
|
924
|
+
state.channel_capabilities = Insika::Media.channel_capabilities(
|
|
925
|
+
rebuild_command(task).payload["channel"]
|
|
926
|
+
)
|
|
927
|
+
# WS9: content parts -> turn. Runs BEFORE stage 2's context build so the
|
|
928
|
+
# transcribed voice text feeds the prompt; a resumed turn was already
|
|
929
|
+
# transcribed (never re-pay the STT call).
|
|
930
|
+
run_media_stage(task, state) unless state.resumed
|
|
931
|
+
|
|
735
932
|
# stage 2: Context. The :prompt hook pair is wrapped INSIDE the
|
|
736
933
|
# ContextBuilder#call — do NOT wrap here (a double-wrap would fire the hooks
|
|
737
934
|
# twice). Hooks is the SAME instance injected into the Builder and here.
|
|
@@ -763,41 +960,252 @@ module Insika
|
|
|
763
960
|
state.requires_approval = resolution.requires_approval
|
|
764
961
|
state.allowed_tools = wrap_tools(assemble_tool_instances(resolution.allowed_tools, state), state, skip)
|
|
765
962
|
state.allowed_skills = resolution.allowed_skills
|
|
963
|
+
record_context_trace(task, state)
|
|
964
|
+
announce_context_skills(task, state)
|
|
766
965
|
drain_and_maybe_suspend(task, actor)
|
|
767
966
|
end
|
|
768
967
|
|
|
968
|
+
# one entry per turn in the ContextTraceStore — tokens per
|
|
969
|
+
# category (the demodulized provider id), the tools-schema estimate and the
|
|
970
|
+
# budget verdict. the entry also carries the prefix
|
|
971
|
+
# fingerprints + the invalidation_reason vs the previous turn, and the
|
|
972
|
+
# categories gain their cache layer. Counts and ids ONLY, never content.
|
|
973
|
+
# nil store = off; the store itself rescues everything (the trace never
|
|
974
|
+
# breaks the turn). -> the sanitized entry (parked on TurnState for the
|
|
975
|
+
# stage-8 cache stamp).
|
|
976
|
+
def record_context_trace(task, state)
|
|
977
|
+
return unless @context_trace_store && task.session_id
|
|
978
|
+
|
|
979
|
+
package = state.context
|
|
980
|
+
# A custom builder that does not produce the full package (fragments +
|
|
981
|
+
# budget) simply has no breakdown to record — never an error.
|
|
982
|
+
return unless package.respond_to?(:fragments) && package.respond_to?(:budget)
|
|
983
|
+
|
|
984
|
+
categories = package.fragments.each_with_object({}) do |f, acc|
|
|
985
|
+
c = (acc[context_category(f.source)] ||= { tokens: 0, fragments: 0, pinned: 0 })
|
|
986
|
+
c[:tokens] += f.tokens || 0
|
|
987
|
+
c[:fragments] += 1
|
|
988
|
+
c[:pinned] += (f.tokens || 0) if f.pinned
|
|
989
|
+
# the category's cache layer (identity | :volatile —
|
|
990
|
+
# stamped by the Builder at production, C3).
|
|
991
|
+
layer = f.layer || :volatile
|
|
992
|
+
c[:layer] ||= layer
|
|
993
|
+
# WHICH skills/tools the fragment carried and WHY — ids only, still
|
|
994
|
+
# content-free. Without this the trace proves a turn injected N tokens of
|
|
995
|
+
# skill but not which ones, and deterministic activation is unauditable
|
|
996
|
+
# after the fact.
|
|
997
|
+
labels = Array(f.labels)
|
|
998
|
+
(c[:labels] ||= []).concat(labels) unless labels.empty?
|
|
999
|
+
end
|
|
1000
|
+
# the prefix chain over the SYSTEM-placement fragments in
|
|
1001
|
+
# canonical (identity-first) render order + the tool-schema serialization.
|
|
1002
|
+
# The reason is computed against the PREVIOUS trace entry (D3): the first
|
|
1003
|
+
# category, in current chain order, whose bytes changed (or vanished).
|
|
1004
|
+
previous = previous_trace_entry(task, state.turn)
|
|
1005
|
+
fingerprints = Insika::PrefixFingerprint.compute(
|
|
1006
|
+
Array(package.fragments).select { |f| f.placement == :system },
|
|
1007
|
+
tool_serial: serialize_tools(state.allowed_tools))
|
|
1008
|
+
reason = Insika::PrefixFingerprint.invalidation_reason(
|
|
1009
|
+
fingerprints, previous && previous["fingerprints"])
|
|
1010
|
+
|
|
1011
|
+
entry = { task_id: task.id, turn: state.turn, at: Time.now.utc.iso8601,
|
|
1012
|
+
cap: package.budget[:cap], used: package.budget[:used],
|
|
1013
|
+
evicted: package.budget[:evicted], categories: categories,
|
|
1014
|
+
tools: { count: state.allowed_tools.size,
|
|
1015
|
+
tokens: estimate_tools_tokens(state.allowed_tools) },
|
|
1016
|
+
fingerprints: fingerprints,
|
|
1017
|
+
cache: { invalidation_reason: reason } }
|
|
1018
|
+
# Park the SANITIZED entry (string keys) — the stage-8 stamp merges into
|
|
1019
|
+
# it and re-records the same key; a raw entry would add a SECOND "cache"
|
|
1020
|
+
# key that sanitize would then ignore (the symbol one wins).
|
|
1021
|
+
state.context_trace_entry = @context_trace_store.record(session_id: task.session_id,
|
|
1022
|
+
entry: entry)
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
# the previous turn's trace entry — the session list minus
|
|
1026
|
+
# THIS (task_id, turn) (an approval-resumed turn re-records over its own key
|
|
1027
|
+
# — never compare to self). `turn` is the turn being recorded, passed
|
|
1028
|
+
# explicitly. -> Hash | nil (first turn of the session).
|
|
1029
|
+
def previous_trace_entry(task, turn)
|
|
1030
|
+
@context_trace_store.for_session(task.session_id)
|
|
1031
|
+
.reject { |x| x["task_id"] == task.id && x["turn"] == turn }
|
|
1032
|
+
.last
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
# the tool-schema yardstick — the SAME serialization the token
|
|
1036
|
+
# estimate uses (estimate_tools_tokens), so the fingerprint and the estimate
|
|
1037
|
+
# never disagree. The digest covers name + description + parameters.inspect,
|
|
1038
|
+
# approximating RubyLLM's rendering (honest in the doc: the reason's job is
|
|
1039
|
+
# the CONTEXT categories; the tool hash is a guard rail).
|
|
1040
|
+
def serialize_tools(tools)
|
|
1041
|
+
tools.map { |t| "#{t.name} #{t.description} #{t.parameters.inspect}" }.join(" ")
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
# the stage-8 stamp — the usage (cached_tokens,
|
|
1045
|
+
# input_tokens) only exists after the provider answered, so a SECOND
|
|
1046
|
+
# UPSERT with the SAME (task_id, turn) merges the cache fields into the
|
|
1047
|
+
# entry parked at prepare_turn (the start-of-turn write stays: a turn that
|
|
1048
|
+
# dies mid-flight still shows its context on the Studio screen). The same
|
|
1049
|
+
# numbers append one entry to the agent's CacheSeriesStore (C6).
|
|
1050
|
+
def stamp_cache_hit(task, state)
|
|
1051
|
+
usage = state.usage || {}
|
|
1052
|
+
input = usage[:input_tokens].to_i
|
|
1053
|
+
cached = usage[:cached_tokens].to_i
|
|
1054
|
+
creation = usage[:cache_creation_tokens].to_i
|
|
1055
|
+
# A4: the billed prefix is input + cached + cache_creation. RubyLLM's
|
|
1056
|
+
# input_tokens is the FRESH input only — cached_tokens is disjoint, not a
|
|
1057
|
+
# subset — so dividing by input alone yields absurd numbers (22000/500 =
|
|
1058
|
+
# 4400%) and renders a full hit as "—" (fresh=0). The denominator is the
|
|
1059
|
+
# whole billed prompt; hit_pct is then always in [0,100].
|
|
1060
|
+
billed = input + cached + creation
|
|
1061
|
+
hit = billed.positive? ? ((cached * 100.0) / billed).round : nil
|
|
1062
|
+
reason = state.context_trace_entry&.dig("cache", "invalidation_reason")
|
|
1063
|
+
|
|
1064
|
+
# The trace merge needs the entry parked at prepare_turn (the UPSERT
|
|
1065
|
+
# replaces the same (task_id, turn)); a failed trace write leaves it nil
|
|
1066
|
+
# and the cache line simply never lands — never a turn failure.
|
|
1067
|
+
if @context_trace_store && state.context_trace_entry && task.session_id
|
|
1068
|
+
@context_trace_store.record(
|
|
1069
|
+
session_id: task.session_id,
|
|
1070
|
+
entry: state.context_trace_entry.merge("cache" => {
|
|
1071
|
+
"hit_pct" => hit, "cached_tokens" => cached, "prompt_tokens" => billed,
|
|
1072
|
+
"invalidation_reason" => reason }))
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
# The per-agent series is INDEPENDENT of the trace store: a deployment
|
|
1076
|
+
# that wires the series without the trace (or whose trace write failed)
|
|
1077
|
+
# still records its cache-hit numbers — reason is simply nil then.
|
|
1078
|
+
@cache_series_store&.record(agent: state.profile.id, entry: {
|
|
1079
|
+
at: Time.now.utc.iso8601, turn: state.turn,
|
|
1080
|
+
hit_pct: hit, cached_tokens: cached, prompt_tokens: billed,
|
|
1081
|
+
invalidation_reason: reason })
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
# Skill bodies that reached the prompt WITHOUT a tool call (`triggers:` or
|
|
1085
|
+
# `skills_eager`) leave no trace in the transcript: there is no load_skill to
|
|
1086
|
+
# render, so an active skill looked exactly like an absent one.
|
|
1087
|
+
#
|
|
1088
|
+
# Emitted HERE rather than in the provider because only the Executor holds the
|
|
1089
|
+
# correlation the Studio's SSE filters on — an event whose meta lacks `task_id`
|
|
1090
|
+
# never reaches a task-scoped subscriber (EventStream::Subscription#matches?).
|
|
1091
|
+
# `skills` (plural, with reasons) marks the CONTEXT path; the load_skill tool
|
|
1092
|
+
# emits the same type with a singular `name`, and the Studio must not conflate them.
|
|
1093
|
+
#
|
|
1094
|
+
# Read from `package.fragments`, which is POST-BUDGET: a body the cut evicted is
|
|
1095
|
+
# not in the prompt, and announcing it as active would make the one surface built
|
|
1096
|
+
# to tell the truth the one that lies. Eviction is reported by the trace's own
|
|
1097
|
+
# `evicted` list, never as an activation.
|
|
1098
|
+
SKILL_BODY_CATEGORY = "skilltrigger"
|
|
1099
|
+
|
|
1100
|
+
def announce_context_skills(task, state)
|
|
1101
|
+
package = state.context
|
|
1102
|
+
return unless package.respond_to?(:fragments)
|
|
1103
|
+
|
|
1104
|
+
skills = Array(package.fragments)
|
|
1105
|
+
.select { |f| context_category(f.source) == SKILL_BODY_CATEGORY }
|
|
1106
|
+
.flat_map { |f| Array(f.labels) }
|
|
1107
|
+
.map { |l| { name: l["name"], reason: l["reason"] || "pack" } }
|
|
1108
|
+
.uniq
|
|
1109
|
+
return if skills.empty?
|
|
1110
|
+
|
|
1111
|
+
emit(:skill_activated, { skills: skills, source: "context" }, task: task)
|
|
1112
|
+
end
|
|
1113
|
+
|
|
1114
|
+
# "Insika::Context::Providers::Prompt" -> "prompt" (a plugin provider keeps
|
|
1115
|
+
# its own demodulized name — still content-free).
|
|
1116
|
+
def context_category(source) = source.to_s.split("::").last.to_s.downcase
|
|
1117
|
+
|
|
1118
|
+
# Same yardstick as the fragments (TokenEstimator), so the categories are
|
|
1119
|
+
# comparable. `parameters` is not guaranteed JSON-safe — inspect it.
|
|
1120
|
+
def estimate_tools_tokens(tools)
|
|
1121
|
+
TokenEstimator.estimate(tools.map { |t| "#{t.name} #{t.description} #{t.parameters.inspect}" }.join(" "))
|
|
1122
|
+
rescue StandardError
|
|
1123
|
+
0
|
|
1124
|
+
end
|
|
1125
|
+
|
|
769
1126
|
# Stages 5-9 (inside the Middleware wrap): assemble chat, the single agent
|
|
770
1127
|
# interaction, persistence, terminal event. `st` is the Middleware-yielded state.
|
|
771
1128
|
def run_turn_body(task, profile, st, actor, timing = nil)
|
|
772
1129
|
# stage 5: assemble chat + check mailbox (send_message only; a workflow does
|
|
773
1130
|
# not use the Insika chat — it orchestrates RubyLLM internally).
|
|
774
1131
|
drain_and_maybe_suspend(task, actor)
|
|
775
|
-
|
|
1132
|
+
# WS4: intent routing, data-gated. Runs BEFORE the agent chat is assembled:
|
|
1133
|
+
# a route that delegates or ends :stuck completes the turn with no ask at
|
|
1134
|
+
# all (routed = true); a plain route is a label + event and the turn
|
|
1135
|
+
# proceeds. Skipped for workflows (no chat to route into) and resumed turns
|
|
1136
|
+
# (already admitted; re-classifying would re-pay the extra call).
|
|
1137
|
+
routed = !workflow_turn?(task) && !st.resumed ? attempt_route(task, profile, st) : false
|
|
1138
|
+
unless workflow_turn?(task) || routed
|
|
776
1139
|
st.chat = create_chat(profile, st)
|
|
777
1140
|
@chat_builder.assemble(st.chat, st, emit: ->(type, data) { emit(type, data, task: task) })
|
|
778
|
-
#
|
|
1141
|
+
# R1: baseline = seeded-history size, before `ask` appends the turn.
|
|
779
1142
|
st.chat_baseline = Array(st.chat.messages).size if st.chat.respond_to?(:messages)
|
|
780
1143
|
end
|
|
781
1144
|
|
|
782
|
-
# guardrails
|
|
1145
|
+
# guardrails: per-turn stream redactor (nil = off).
|
|
783
1146
|
st.output_filter = @content_filter_factory&.call(st)
|
|
784
1147
|
|
|
785
1148
|
# stage 6: the turn's single agent interaction (send_message -> chat.ask;
|
|
786
|
-
# trigger_workflow -> workflow.call).
|
|
787
|
-
|
|
1149
|
+
# trigger_workflow -> workflow.call). A routed turn's content IS its route
|
|
1150
|
+
# action's answer (a delegate's reply or the stuck lead-in).
|
|
1151
|
+
content = routed ? st.response_content : run_agent_stage(task, st, timing)
|
|
788
1152
|
st.response_content = content # after_task OutputValidator inspects this
|
|
789
1153
|
|
|
1154
|
+
# the model-visible record — what the provider received this
|
|
1155
|
+
# turn, captured at the boundary BEFORE stage 8 persists the checkpoint
|
|
1156
|
+
# (turn n's provider-visible stream == checkpoint(turn n+1).messages).
|
|
1157
|
+
# Skipped for workflows (they orchestrate RubyLLM inside the workflow
|
|
1158
|
+
# body — the engine cannot see those calls, stated in the conformance
|
|
1159
|
+
# scope) and absent-store runs (parity).
|
|
1160
|
+
record_model_visible(task, st) if !workflow_turn?(task) && st.chat
|
|
1161
|
+
|
|
1162
|
+
# the :enforce boundary — a CUT of the final content BEFORE
|
|
1163
|
+
# persistence/delivery (after_task fires too late to change what is
|
|
1164
|
+
# persisted). The cut text is what persists, delivers and terminates.
|
|
1165
|
+
if @grounding_enforcer
|
|
1166
|
+
content, st = @grounding_enforcer.call(task, st, content)
|
|
1167
|
+
st.response_content = content
|
|
1168
|
+
end
|
|
1169
|
+
|
|
790
1170
|
# stage 8: Persistence (fixed order checkpoint->session->task). pure drain!
|
|
791
1171
|
# (NEVER suspends at stage 8 — forbidden window): a :pause here arms the flag
|
|
792
1172
|
# but is not honored (last stage); :cancel here still raises.
|
|
793
1173
|
actor.drain!
|
|
794
|
-
persist_turn(task, profile, st, content)
|
|
1174
|
+
persist_turn(task, profile, st, content, timing: timing)
|
|
1175
|
+
|
|
1176
|
+
# the cache-hit stamp — the usage exists only now. The
|
|
1177
|
+
# stamped entry is durable before anything is delivered (same slot as the
|
|
1178
|
+
# enforcer). Best-effort by construction (both stores rescue).
|
|
1179
|
+
stamp_cache_hit(task, st)
|
|
795
1180
|
|
|
796
1181
|
# stage 9: Response. usage (tokens) captured at stage 6 travels in the
|
|
797
1182
|
# terminal event -> /v1/responses usage + Telemetry (OTEL).
|
|
798
1183
|
timing&.mark(:done)
|
|
799
1184
|
data = { task_id: task.id, content: content, usage: st.usage }
|
|
1185
|
+
# WS4: the intent route rides the terminal additively (like outcome) — a
|
|
1186
|
+
# consumer aggregating by route does not need the stream.
|
|
1187
|
+
data[:route] = st.route.to_s if st.route
|
|
1188
|
+
# WS9: a turn whose message came from a VOICE note is marked — the
|
|
1189
|
+
# consumer's signal the person spoke (text was transcribed).
|
|
1190
|
+
data[:source] = :voice if st.message_source == :voice
|
|
1191
|
+
# WS9 (saída): media the agent GENERATED this turn (image/audio clips).
|
|
1192
|
+
# Additive sibling — the answer text stays text on purpose; the channel
|
|
1193
|
+
# consumes the parts next to it. Absent when nothing was generated.
|
|
1194
|
+
data[:output_parts] = st.output_parts if st.output_parts && !st.output_parts.empty?
|
|
800
1195
|
data[:timing] = timing.to_h if timing # opt-in TTFB breakdown (INSIKA_TURN_TIMING)
|
|
1196
|
+
# best-effort persist of the same timing onto the task record —
|
|
1197
|
+
# the Studio task page reads it from there. A failure here must not re-fail
|
|
1198
|
+
# the turn (the task is already committed and the event already carries it).
|
|
1199
|
+
persist_turn_timing(task, timing)
|
|
1200
|
+
# WS5: the agent declared it cannot proceed (signal_stuck). The turn still
|
|
1201
|
+
# COMPLETES (its final message was published) — but the consumer must be able
|
|
1202
|
+
# to act on that, so the contract carries it twice: a dedicated :turn_stuck
|
|
1203
|
+
# event (subscribable) and an additive `outcome` sibling on the terminal event.
|
|
1204
|
+
if (stuck = st.stuck_outcome)
|
|
1205
|
+
emit(:turn_stuck, { task_id: task.id, agent: profile.id.to_s,
|
|
1206
|
+
reason: stuck[:reason], message: content }, task: task)
|
|
1207
|
+
data[:outcome] = :stuck
|
|
1208
|
+
end
|
|
801
1209
|
emit(:task_completed, data, task: task)
|
|
802
1210
|
end
|
|
803
1211
|
|
|
@@ -805,13 +1213,30 @@ module Insika
|
|
|
805
1213
|
command_type(task).to_s == "trigger_workflow"
|
|
806
1214
|
end
|
|
807
1215
|
|
|
1216
|
+
# the model-visible record of ONE ask — the chat at the
|
|
1217
|
+
# provider boundary (instructions + tool schemas + the message stream),
|
|
1218
|
+
# persisted under the checkpoint's turn number (turn n's stream ==
|
|
1219
|
+
# checkpoint(turn n+1).messages). Best-effort: the store rescues
|
|
1220
|
+
# everything, and the absent-store path is parity. `chat` defaults to the
|
|
1221
|
+
# turn's own chat; the routing classifier passes its own.
|
|
1222
|
+
def record_model_visible(task, st, chat = nil, part: "turn")
|
|
1223
|
+
return unless @model_visible_trace_store
|
|
1224
|
+
|
|
1225
|
+
c = chat || st.chat
|
|
1226
|
+
return unless c
|
|
1227
|
+
|
|
1228
|
+
@model_visible_trace_store.record(
|
|
1229
|
+
task_id: task.id, turn: st.turn + 1, part: part,
|
|
1230
|
+
payload: Insika::ModelVisible.capture(c))
|
|
1231
|
+
end
|
|
1232
|
+
|
|
808
1233
|
# Stage 6: the single agent interaction. Returns the turn's final content.
|
|
809
1234
|
def run_agent_stage(task, state, timing = nil)
|
|
810
1235
|
if workflow_turn?(task)
|
|
811
1236
|
# workflow = a Ruby callable that orchestrates RubyLLM internally (RubyLLM
|
|
812
1237
|
# First). tools: are the SAME instances filtered by the Resolution and
|
|
813
1238
|
# enveloped (stage 7) — the workflow inherits timeout/side-effect/skip.
|
|
814
|
-
#
|
|
1239
|
+
# the EXPOSED surface — the run (== task.id) is announced on
|
|
815
1240
|
# the stream (:workflow_started), the RETURN is validated against the
|
|
816
1241
|
# output_schema (WorkflowSchemaError -> :workflow_schema stage), and the
|
|
817
1242
|
# typed output is published (:workflow_completed).
|
|
@@ -826,43 +1251,37 @@ module Insika
|
|
|
826
1251
|
emit(:workflow_completed, { run_id: task.id, workflow: definition.name, output: output }, task: task)
|
|
827
1252
|
output
|
|
828
1253
|
else
|
|
829
|
-
filter = state.output_filter #
|
|
1254
|
+
filter = state.output_filter # nil = off (stream untouched)
|
|
830
1255
|
timing&.mark(:ask)
|
|
831
1256
|
# TurnOutput owns what the customer is allowed to read: chunks ride
|
|
832
1257
|
# :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".
|
|
1258
|
+
# :content. Registered on the chat (fresh per turn/attempt, no leak).
|
|
1259
|
+
# With WS3 reliability the attempts build their own chats + outputs.
|
|
1260
|
+
output = nil
|
|
845
1261
|
asked = nil
|
|
846
1262
|
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
|
|
1263
|
+
result = @reliability ? run_reliable_ask(task, s, filter, timing)
|
|
1264
|
+
: run_single_ask(task, s, filter, timing)
|
|
1265
|
+
output = result[:output]
|
|
1266
|
+
asked = result[:asked]
|
|
1267
|
+
result[:response]
|
|
855
1268
|
end
|
|
856
1269
|
# release the redactor's retained tail (a value that never completed into a
|
|
857
1270
|
# match is emitted redacted-if-needed, not lost) before reading anything back.
|
|
858
1271
|
output.flush
|
|
859
|
-
|
|
1272
|
+
# MERGED, not overwritten: a WS4 routing call already banked its tokens in
|
|
1273
|
+
# state.usage before the ask — the classifier's cost must survive the ask
|
|
1274
|
+
# (it feeds the EdgeLimiter's ceiling/budget and the terminal usage).
|
|
1275
|
+
unless halted?(response)
|
|
1276
|
+
state.usage = merge_usage(with_model_source(usage_of(response), state.model_selection),
|
|
1277
|
+
state.usage)
|
|
1278
|
+
end
|
|
860
1279
|
|
|
861
1280
|
# BOUNDARY BEFORE THE ANSWER GOES OUT. A cancel that arrived while the provider
|
|
862
1281
|
# was working used to be observed at stage 8 — AFTER `:content` had already been
|
|
863
1282
|
# published — so the customer read the answer of a turn that then terminated
|
|
864
1283
|
# `:cancelled` and persisted nothing: text delivered, transcript silent about it.
|
|
865
|
-
# Honoring it here is what makes `interrupt`
|
|
1284
|
+
# Honoring it here is what makes `interrupt` mean anything, and it
|
|
866
1285
|
# is a safe boundary: the tool batch is finished and nothing is half applied.
|
|
867
1286
|
# A `:pause` is deliberately NOT honored here (drain!, not the suspending form):
|
|
868
1287
|
# holding a completed answer for an operator would strand it unpublished.
|
|
@@ -872,7 +1291,400 @@ module Insika
|
|
|
872
1291
|
end
|
|
873
1292
|
end
|
|
874
1293
|
|
|
875
|
-
#
|
|
1294
|
+
# stage 6, plain path: the single ask on the assembled chat. Fresh TurnOutput
|
|
1295
|
+
# + steer wiring per interaction (registered on state.chat, which the solve
|
|
1296
|
+
# already assembled). -> { response:, asked:, output: }.
|
|
1297
|
+
def run_single_ask(task, state, filter, timing)
|
|
1298
|
+
output = new_turn_output(task, state, filter)
|
|
1299
|
+
wire_chat_output(task, state, output)
|
|
1300
|
+
asked = ask_on(task, state, state.chat, output, timing)
|
|
1301
|
+
{ response: asked, asked: asked, output: output }
|
|
1302
|
+
end
|
|
1303
|
+
|
|
1304
|
+
# stage 6, WS3 path: the Reliability coordinator drives retries, backoff,
|
|
1305
|
+
# circuit breaker and the fallback rotation. Each ATTEMPT gets a fresh chat
|
|
1306
|
+
# + output (a failed `ask` leaves its message in the chat, so re-asking the
|
|
1307
|
+
# same one would double the input) and, on a fallback, state.model_selection
|
|
1308
|
+
# follows — the turn's usage is attributed to the model that actually spoke
|
|
1309
|
+
# ("contabilizado no trace"). -> { response:, asked:, output: }.
|
|
1310
|
+
def run_reliable_ask(task, state, filter, timing)
|
|
1311
|
+
policy = state.profile.respond_to?(:reliability) ? state.profile.reliability : nil
|
|
1312
|
+
return run_single_ask(task, state, filter, timing) if policy.nil? || @reliability.nil?
|
|
1313
|
+
|
|
1314
|
+
attempt_output = nil
|
|
1315
|
+
attempt_asked = nil
|
|
1316
|
+
primary = state.model_selection
|
|
1317
|
+
response = @reliability.call(
|
|
1318
|
+
policy: policy, tenant: task_tenant(task), agent: state.profile.id.to_s,
|
|
1319
|
+
selection: primary, chain: reliability_chain(state)
|
|
1320
|
+
) do |selection, tries|
|
|
1321
|
+
first_attempt = state.chat && selection == primary && tries == 1
|
|
1322
|
+
if selection != primary
|
|
1323
|
+
state.model_selection = selection # attribution follows the fallback
|
|
1324
|
+
end
|
|
1325
|
+
unless first_attempt
|
|
1326
|
+
chat = build_attempt_chat(state, selection)
|
|
1327
|
+
state.chat = chat
|
|
1328
|
+
end
|
|
1329
|
+
attempt_output = new_turn_output(task, state, filter)
|
|
1330
|
+
wire_chat_output(task, state, attempt_output)
|
|
1331
|
+
attempt_asked = ask_on(task, state, state.chat, attempt_output, timing)
|
|
1332
|
+
attempt_asked
|
|
1333
|
+
end
|
|
1334
|
+
{ response: response, asked: attempt_asked, output: attempt_output }
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
# The fallback chain for WS3: profile's `reliability["fallback"]` refs first,
|
|
1338
|
+
# then the platform-resolved fallbacks (ModelSelection). Each node is a
|
|
1339
|
+
# ModelSelection (the SAME duck the primary is — usage attribution and
|
|
1340
|
+
# apply_params just work), source: :fallback, params inherited from the
|
|
1341
|
+
# primary. Deduped by ref, primary excluded.
|
|
1342
|
+
def reliability_chain(state)
|
|
1343
|
+
primary = state.model_selection
|
|
1344
|
+
refs = Array((state.profile.reliability || {})["fallback"]).map(&:to_s)
|
|
1345
|
+
nodes = refs.filter_map { |r| parse_model_ref(r) }.reject { |n| n[:model].to_s.empty? }
|
|
1346
|
+
nodes.concat(Array(primary.fallbacks).map { |f| { model: f[:model], provider: f[:provider] } })
|
|
1347
|
+
seen = { ref_of(primary) => true }
|
|
1348
|
+
nodes.filter_map do |node|
|
|
1349
|
+
ref = model_ref(node)
|
|
1350
|
+
# normalize "model" vs "provider/model": a provider-less ref IS the same
|
|
1351
|
+
# physical model as any known "provider/model" spelling of it — the same
|
|
1352
|
+
# model must never be tried twice just because one spelling omits the
|
|
1353
|
+
# provider (WS3: fallback ["deepseek-v4-flash"] under primary
|
|
1354
|
+
# deepseek/deepseek-v4-flash used to re-ask the dropped primary). A
|
|
1355
|
+
# qualified ref still matches exactly.
|
|
1356
|
+
duplicate = ref.include?("/") ? seen[ref]
|
|
1357
|
+
: seen.keys.any? { |known| known.split("/").last == ref }
|
|
1358
|
+
next if duplicate
|
|
1359
|
+
|
|
1360
|
+
seen[ref] = true
|
|
1361
|
+
Insika::ModelSelection.new(model: node[:model], provider: node[:provider],
|
|
1362
|
+
source: :fallback, params: primary.params, fallbacks: [])
|
|
1363
|
+
end
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1366
|
+
def ref_of(selection) = model_ref(selection)
|
|
1367
|
+
|
|
1368
|
+
# --- WS9 media ------------------------------------------------------
|
|
1369
|
+
#
|
|
1370
|
+
# Content parts on the command -> a turn: audio parts are transcribed (the
|
|
1371
|
+
# text enters the message marked `source: :voice` — the consumer's signal
|
|
1372
|
+
# the person SPOKE), image parts become the ask's attachments (the model
|
|
1373
|
+
# sees them; the provider bills them — usage flows) and the first URL is
|
|
1374
|
+
# deposited as `ctx.image_url` for data/HTTP tools. The engine transports
|
|
1375
|
+
# media, never meaning: no speech/vision logic beyond the call itself.
|
|
1376
|
+
def run_media_stage(task, state)
|
|
1377
|
+
# a consumer that pre-transcribed voice text labels it `source: voice`;
|
|
1378
|
+
# the marker rides the turn even when there are no audio PARTS left.
|
|
1379
|
+
state.message_source = :voice if rebuild_command(task).payload["source"].to_s == "voice"
|
|
1380
|
+
|
|
1381
|
+
parts = Insika::Media.parts(rebuild_command(task).payload["parts"])
|
|
1382
|
+
return if parts.empty?
|
|
1383
|
+
|
|
1384
|
+
voice = Insika::Media.audio_parts(parts)
|
|
1385
|
+
if voice.any?
|
|
1386
|
+
text = voice.map { |p| media_transcribe(p.url) }.reject(&:empty?).join(" ")
|
|
1387
|
+
state.message = [state.message.to_s, text].reject(&:empty?).join("\n")
|
|
1388
|
+
state.message_source = :voice
|
|
1389
|
+
end
|
|
1390
|
+
|
|
1391
|
+
images = Insika::Media.image_parts(parts)
|
|
1392
|
+
if images.any?
|
|
1393
|
+
state.media_attachments = images.map { |p| media_attachment(p.url) }
|
|
1394
|
+
# First image URL for data tools (`{{ctx.image_url}}`) — photo analysis
|
|
1395
|
+
# outside the prompt. The model still sees the attachment; the tool
|
|
1396
|
+
# gets the original URL (its own egress applies when it fetches).
|
|
1397
|
+
state.turn_context = (state.turn_context || {}).merge(image_url: images.first.url)
|
|
1398
|
+
end
|
|
1399
|
+
|
|
1400
|
+
# A media-only turn (a voice note with no caption) is legitimate — the
|
|
1401
|
+
# surfaces admit it — but it must leave this stage with something to ask
|
|
1402
|
+
# about. Empty text AND no attachment means the parts carried nothing the
|
|
1403
|
+
# engine could use (a transcription that came back blank): fail loudly at
|
|
1404
|
+
# :media rather than ask the provider about nothing.
|
|
1405
|
+
return unless state.message.to_s.strip.empty? && state.media_attachments.nil?
|
|
1406
|
+
|
|
1407
|
+
raise Insika::MediaError, "the message parts produced no text and no attachment"
|
|
1408
|
+
end
|
|
1409
|
+
|
|
1410
|
+
# The STT seam: the injected transcriber (specs), else the default
|
|
1411
|
+
# (fetch + RubyLLM::Transcription — lazy require). A failed transcription
|
|
1412
|
+
# fails the turn loudly (MediaError -> :media): a voice message that was
|
|
1413
|
+
# not heard must not become a hallucinated one.
|
|
1414
|
+
def media_transcribe(url)
|
|
1415
|
+
transcriber = @media || (@default_transcriber ||= Insika::Media.default_transcriber(
|
|
1416
|
+
stt_model: Insika::EnvSchema.read("INSIKA_STT_MODEL"),
|
|
1417
|
+
stt_language: Insika::EnvSchema.read("INSIKA_STT_LANGUAGE")
|
|
1418
|
+
))
|
|
1419
|
+
transcriber.call(url)
|
|
1420
|
+
end
|
|
1421
|
+
|
|
1422
|
+
# An image part -> the ask's attachment. RubyLLM required lazily (load-guard).
|
|
1423
|
+
#
|
|
1424
|
+
# The bytes come through OUR fetch (`Media.fetch_binary`), which is
|
|
1425
|
+
# egress-guarded — the URL is CONSUMER input, so a private/metadata target
|
|
1426
|
+
# fails the turn loudly at :media — and SIZE-CAPPED. Handing the raw URL to
|
|
1427
|
+
# `RubyLLM::Attachment` instead left the fetch to the gem, whose
|
|
1428
|
+
# `fetch_content` reads the whole response with no ceiling: a hostile URL
|
|
1429
|
+
# answering an endless body grows this process until it dies. An io-like
|
|
1430
|
+
# source (StringIO) is the branch of Attachment that takes bytes we already
|
|
1431
|
+
# hold; the provider then gets base64 rather than the URL, which every
|
|
1432
|
+
# vision provider accepts.
|
|
1433
|
+
def media_attachment(url)
|
|
1434
|
+
require "ruby_llm"
|
|
1435
|
+
require "stringio"
|
|
1436
|
+
|
|
1437
|
+
bytes = Insika::Media.fetch_binary(url, max_bytes: Insika::Media::MAX_IMAGE_BYTES)
|
|
1438
|
+
RubyLLM::Attachment.new(StringIO.new(bytes), filename: media_filename(url))
|
|
1439
|
+
end
|
|
1440
|
+
|
|
1441
|
+
# The URL's basename, for the attachment's mime sniff (".png" -> image/png;
|
|
1442
|
+
# a URL with no filename falls back to the content sniff RubyLLM does).
|
|
1443
|
+
def media_filename(url)
|
|
1444
|
+
name = File.basename(URI.parse(url).path.to_s)
|
|
1445
|
+
name.empty? ? nil : name
|
|
1446
|
+
rescue URI::InvalidURIError
|
|
1447
|
+
nil
|
|
1448
|
+
end
|
|
1449
|
+
|
|
1450
|
+
# --- WS4 intent routing --------------------------------------------
|
|
1451
|
+
#
|
|
1452
|
+
# The turn's message is classified into one of the profile's routes with a
|
|
1453
|
+
# CHEAP model (data-gated: no `routes` on the profile = byte-identical turn).
|
|
1454
|
+
# -> true when the route took over the turn (delegated / stuck — no ask
|
|
1455
|
+
# happens); false when the turn proceeds normally. Classification happens on
|
|
1456
|
+
# a fresh chat carrying ONLY the auto-generated route prompt (no identity,
|
|
1457
|
+
# no tools — it is a router, not the agent); its tokens ride the turn's
|
|
1458
|
+
# usage, so the trace, the token ceiling and the budget all see the cost.
|
|
1459
|
+
def attempt_route(task, profile, state)
|
|
1460
|
+
meta = Insika::Routing.normalize(profile.routes)
|
|
1461
|
+
return false unless meta
|
|
1462
|
+
return false unless route_model(meta, profile)
|
|
1463
|
+
|
|
1464
|
+
selection = route_selection(meta, profile)
|
|
1465
|
+
classification = classify_route(selection, meta, state.message, task, state)
|
|
1466
|
+
return false if classification.nil? # the classifier call failed — routing is additive
|
|
1467
|
+
|
|
1468
|
+
route = classification[:route]
|
|
1469
|
+
state.route = route
|
|
1470
|
+
# MERGED, not assigned: a WS9 transcription may already have banked its
|
|
1471
|
+
# tokens in state.usage (the classifier call must not erase them).
|
|
1472
|
+
state.usage = merge_usage(with_model_source(usage_of(classification[:response]), selection),
|
|
1473
|
+
state.usage)
|
|
1474
|
+
emit(:route_classified,
|
|
1475
|
+
{ task_id: task.id, agent: profile.id.to_s, route: route.to_s,
|
|
1476
|
+
model: selection.model, usage: state.usage },
|
|
1477
|
+
task: task)
|
|
1478
|
+
|
|
1479
|
+
entry = meta[:entries].find { |e| e.name == route.to_s }
|
|
1480
|
+
apply_route_action(task, profile, state, entry)
|
|
1481
|
+
end
|
|
1482
|
+
|
|
1483
|
+
# The classifier call, its answer parsed back into a route.
|
|
1484
|
+
# -> { route:, response: } | nil (nil = the call failed — the turn proceeds
|
|
1485
|
+
# unrouted rather than paying a wrong label or dying for an additive step).
|
|
1486
|
+
def classify_route(selection, meta, message, task, st)
|
|
1487
|
+
response = route_ask(selection, Insika::Routing.classifier_prompt(meta), message, task, st)
|
|
1488
|
+
{ route: Insika::Routing.parse(route_response_text(response), meta), response: response }
|
|
1489
|
+
rescue StandardError
|
|
1490
|
+
nil
|
|
1491
|
+
end
|
|
1492
|
+
|
|
1493
|
+
# The cheap classifier's model: routes["model"] wins, the agent's own model
|
|
1494
|
+
# otherwise. No model anywhere = no routing.
|
|
1495
|
+
def route_model(meta, profile)
|
|
1496
|
+
ref = meta[:model].to_s
|
|
1497
|
+
ref = profile.model.to_s if ref.empty?
|
|
1498
|
+
!ref.empty?
|
|
1499
|
+
end
|
|
1500
|
+
|
|
1501
|
+
def route_selection(meta, profile)
|
|
1502
|
+
ref = meta[:model].to_s
|
|
1503
|
+
ref = profile.model.to_s if ref.empty?
|
|
1504
|
+
parsed = parse_model_ref(ref) || {}
|
|
1505
|
+
provider = parsed[:provider].nil? ? profile.provider : parsed[:provider].to_sym
|
|
1506
|
+
Insika::ModelSelection.new(model: parsed[:model], provider: provider, source: :routing)
|
|
1507
|
+
end
|
|
1508
|
+
|
|
1509
|
+
# A fresh chat for the routing model with ONLY the generated prompt. RubyLLM
|
|
1510
|
+
# required lazily, exactly like create_chat (the load-guard holds).
|
|
1511
|
+
# the classifier is model-visible, so it is logged — the ONE
|
|
1512
|
+
# engine-internal ask the conformance spec adds a record for (part
|
|
1513
|
+
# "routing", same turn number as the answer ask).
|
|
1514
|
+
def route_ask(selection, prompt, message, task, st)
|
|
1515
|
+
require "ruby_llm"
|
|
1516
|
+
chat = (@llm || RubyLLM).chat(model: selection.model, provider: selection.provider,
|
|
1517
|
+
assume_model_exists: selection.assume_model_exists?)
|
|
1518
|
+
chat.with_instructions(prompt) if chat.respond_to?(:with_instructions)
|
|
1519
|
+
response = chat.ask(message.to_s)
|
|
1520
|
+
record_model_visible(task, st, chat, part: "routing")
|
|
1521
|
+
response
|
|
1522
|
+
end
|
|
1523
|
+
|
|
1524
|
+
def route_response_text(response)
|
|
1525
|
+
response.respond_to?(:content) ? response.content.to_s : response.to_s
|
|
1526
|
+
end
|
|
1527
|
+
|
|
1528
|
+
# The route's config decides the turn's fate: nothing (a label), a DELEGATE
|
|
1529
|
+
# (an existing agent answers; its reply IS the turn's), or STUCK (WS5 — the
|
|
1530
|
+
# turn ends with the stuck outcome; the consumer interprets it).
|
|
1531
|
+
def apply_route_action(task, profile, state, entry)
|
|
1532
|
+
return false unless entry
|
|
1533
|
+
|
|
1534
|
+
if entry.delegate && !entry.delegate.empty?
|
|
1535
|
+
delegate_route(profile, state, entry.delegate)
|
|
1536
|
+
true
|
|
1537
|
+
elsif entry.stuck
|
|
1538
|
+
message = entry.message.empty? ? entry.description : entry.message
|
|
1539
|
+
state.stuck_outcome = { reason: "route:#{state.route}", message: message }
|
|
1540
|
+
state.response_content = message
|
|
1541
|
+
true
|
|
1542
|
+
else
|
|
1543
|
+
false
|
|
1544
|
+
end
|
|
1545
|
+
end
|
|
1546
|
+
|
|
1547
|
+
# WS4 delegate action: the route names an existing agent — the turn is
|
|
1548
|
+
# handed to it (the sync subagent machinery) and the child's answer IS the
|
|
1549
|
+
# parent's answer. A missing agent or a failed child fails the turn: never
|
|
1550
|
+
# fabricate the customer's reply.
|
|
1551
|
+
#
|
|
1552
|
+
# The depth comes from the PARENT's turn context, +1, and is capped here —
|
|
1553
|
+
# this path does not go through `plan_subagent` (a route has no subagents
|
|
1554
|
+
# allowlist to check against), so a hardcoded depth of 1 made an A -> B -> A
|
|
1555
|
+
# route pair a loop with no floor: every hop reclassifies (a paid ask) and
|
|
1556
|
+
# spawns another child, forever.
|
|
1557
|
+
def delegate_route(profile, state, agent_id)
|
|
1558
|
+
child = @profiles[agent_id.to_s]
|
|
1559
|
+
raise Insika::RoutingError, "route delegate agent '#{agent_id}' not configured" if child.nil?
|
|
1560
|
+
|
|
1561
|
+
depth = (state.turn_context&.dig(:delegation_depth) || 0) + 1
|
|
1562
|
+
cap = SubagentGraph.depth_cap
|
|
1563
|
+
if depth > cap
|
|
1564
|
+
raise Insika::RoutingError,
|
|
1565
|
+
"routed delegate '#{agent_id}' at depth #{depth} exceeds cap #{cap}"
|
|
1566
|
+
end
|
|
1567
|
+
|
|
1568
|
+
result = spawn_and_await_child(child, state.message, depth, state)
|
|
1569
|
+
if result[:error]
|
|
1570
|
+
raise Insika::RoutingError, "routed delegate '#{agent_id}' failed: #{result[:error]}"
|
|
1571
|
+
end
|
|
1572
|
+
|
|
1573
|
+
state.response_content = result[:text].to_s
|
|
1574
|
+
end
|
|
1575
|
+
|
|
1576
|
+
# The WS4 classifier's tokens, summed over the ask's (a call that reported no
|
|
1577
|
+
# usage contributes nothing). The turn's own model/source win for attribution
|
|
1578
|
+
# — the routing model's identity lives on the :route_classified event.
|
|
1579
|
+
def merge_usage(main, extra)
|
|
1580
|
+
return main || extra if main.nil? || extra.nil?
|
|
1581
|
+
|
|
1582
|
+
Insika::Routing::TOKEN_FIELDS.each_with_object(main.dup) do |k, acc|
|
|
1583
|
+
next if extra[k].nil?
|
|
1584
|
+
next unless main.key?(k) || extra[k].to_i.positive?
|
|
1585
|
+
|
|
1586
|
+
acc[k] = main[k].to_i + extra[k].to_i
|
|
1587
|
+
end
|
|
1588
|
+
end
|
|
1589
|
+
|
|
1590
|
+
# "provider/model" for any selection duck (ModelSelection | { model:, provider: }).
|
|
1591
|
+
def model_ref(selection)
|
|
1592
|
+
model = selection.respond_to?(:model) ? selection.model.to_s : selection[:model].to_s
|
|
1593
|
+
provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
|
|
1594
|
+
provider ? "#{provider}/#{model}" : model
|
|
1595
|
+
end
|
|
1596
|
+
|
|
1597
|
+
# "provider/model" -> { model:, provider: }; "model" -> { model:, provider: nil }.
|
|
1598
|
+
def parse_model_ref(entry)
|
|
1599
|
+
s = entry.to_s.strip
|
|
1600
|
+
return nil if s.empty?
|
|
1601
|
+
|
|
1602
|
+
if s.include?("/")
|
|
1603
|
+
provider, model = s.split("/", 2)
|
|
1604
|
+
{ model: model, provider: presence_or_nil(provider)&.to_sym }
|
|
1605
|
+
else
|
|
1606
|
+
{ model: s, provider: nil }
|
|
1607
|
+
end
|
|
1608
|
+
end
|
|
1609
|
+
|
|
1610
|
+
def presence_or_nil(value)
|
|
1611
|
+
v = value.to_s.strip
|
|
1612
|
+
v.empty? ? nil : v
|
|
1613
|
+
end
|
|
1614
|
+
|
|
1615
|
+
# A fresh chat for a retry/fallback attempt: REASSEMBLED from the same turn
|
|
1616
|
+
# state (the seed history is identical), baseline reset -> the transcript
|
|
1617
|
+
# recorded from than point is the attempt that spoke.
|
|
1618
|
+
def build_attempt_chat(state, selection)
|
|
1619
|
+
chat = build_chat(selection, state.model_selection)
|
|
1620
|
+
@chat_builder.assemble(chat, state, emit: ->(type, data) { emit(type, data, task: state.task) })
|
|
1621
|
+
state.chat_baseline = Array(chat.messages).size if chat.respond_to?(:messages)
|
|
1622
|
+
chat
|
|
1623
|
+
end
|
|
1624
|
+
|
|
1625
|
+
def new_turn_output(task, state, filter)
|
|
1626
|
+
TurnOutput.new(filter: filter, emit: ->(type, data) { emit(type, data, task: task) },
|
|
1627
|
+
public_intermediate: state.profile.stream_public?(:intermediate))
|
|
1628
|
+
end
|
|
1629
|
+
|
|
1630
|
+
# The message-boundary + steer wiring ON the current chat. Registered
|
|
1631
|
+
# AFTER TurnOutput so the publishing decision for a message is made before
|
|
1632
|
+
# anything is appended after it — the gem's callbacks are additive and run
|
|
1633
|
+
# in registration order.
|
|
1634
|
+
def wire_chat_output(task, state, output)
|
|
1635
|
+
chat = state.chat
|
|
1636
|
+
chat.after_message { |message| output.message_ended(message) } if chat.respond_to?(:after_message)
|
|
1637
|
+
install_steer_injector(task, state)
|
|
1638
|
+
end
|
|
1639
|
+
|
|
1640
|
+
# The ask itself, chunk-by-chunk (WS3 attempts and the plain path share it).
|
|
1641
|
+
# With INSIKA_TURN_TIMING the FIRST content chunk also emits the live
|
|
1642
|
+
# :ttft event — the streaming envelope's TTFB signal (WS6), additive. The
|
|
1643
|
+
# emit is gated to that first chunk: a probe proved the old code re-emitted
|
|
1644
|
+
# :ttft on EVERY content chunk (3 chunks = 3 insika.ttft frames); the spec
|
|
1645
|
+
# passed because FakeChat emits a single chunk.
|
|
1646
|
+
def ask_on(task, state, chat, output, timing)
|
|
1647
|
+
public_thinking = state.profile.stream_public?(:thinking)
|
|
1648
|
+
ttft_sent = false
|
|
1649
|
+
each_chunk = lambda do |chunk|
|
|
1650
|
+
emit_thinking(chunk, task, public: public_thinking)
|
|
1651
|
+
next unless chunk.content
|
|
1652
|
+
|
|
1653
|
+
timing&.mark(:first_token) # first-write-wins -> the PROVIDER's TTFB
|
|
1654
|
+
unless ttft_sent
|
|
1655
|
+
emit_ttft(task, timing) if timing
|
|
1656
|
+
ttft_sent = true
|
|
1657
|
+
end
|
|
1658
|
+
output.push(chunk.content)
|
|
1659
|
+
end
|
|
1660
|
+
# WS9: image parts ride the ask as attachments (only then — a chat whose
|
|
1661
|
+
# ask has no `with:` keeps working, and the plain path is byte-identical).
|
|
1662
|
+
# An image with no caption asks with NIL, not "": an empty text part is a
|
|
1663
|
+
# thing some providers refuse, and nil is how RubyLLM says "attachments
|
|
1664
|
+
# only".
|
|
1665
|
+
if state.media_attachments
|
|
1666
|
+
text = state.message.to_s.empty? ? nil : state.message
|
|
1667
|
+
chat.ask(text, with: state.media_attachments, &each_chunk)
|
|
1668
|
+
else
|
|
1669
|
+
chat.ask(state.message, &each_chunk)
|
|
1670
|
+
end
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
# The provider's TTFB as a live event (data: ttft_ms) — only under
|
|
1674
|
+
# INSIKA_TURN_TIMING, so absent by default (parity). Rides the SINGLE
|
|
1675
|
+
# emitter: a hand-built meta here lacked `tenant`, and a tenant-scoped
|
|
1676
|
+
# /v1/events subscription is fail-closed on it — the tenant's own TTFB was
|
|
1677
|
+
# invisible to the tenant.
|
|
1678
|
+
def emit_ttft(task, timing)
|
|
1679
|
+
ttft = timing.to_h[:ttft_ms]
|
|
1680
|
+
return if ttft.nil?
|
|
1681
|
+
|
|
1682
|
+
emit(:ttft, { ttft_ms: ttft }, task: task)
|
|
1683
|
+
rescue StandardError
|
|
1684
|
+
nil
|
|
1685
|
+
end
|
|
1686
|
+
|
|
1687
|
+
# wires the tool-batch boundary that lets a message which arrived
|
|
876
1688
|
# mid-run enter the conversation. No-op unless the agent asked for `steer`: an
|
|
877
1689
|
# unregistered callback is the difference between a feature that is off and one that
|
|
878
1690
|
# is on and finds nothing.
|
|
@@ -948,9 +1760,9 @@ module Insika
|
|
|
948
1760
|
#
|
|
949
1761
|
# Three deliberate omissions:
|
|
950
1762
|
# · the guardrail filter is NOT applied — it accumulates the PERSISTED content
|
|
951
|
-
#
|
|
1763
|
+
# and pushing reasoning through it would corrupt the turn's answer;
|
|
952
1764
|
# · `timing.mark(:first_token)` stays on the content chunks — ttft is the
|
|
953
|
-
# PROVIDER's first token (
|
|
1765
|
+
# PROVIDER's first token ('s baselines measure that, not the first
|
|
954
1766
|
# thought, and not when TurnOutput publishes the answer);
|
|
955
1767
|
# · nothing is persisted — the reasoning is not part of the conversation.
|
|
956
1768
|
#
|
|
@@ -967,7 +1779,7 @@ module Insika
|
|
|
967
1779
|
emit(:thinking, data, task: task)
|
|
968
1780
|
end
|
|
969
1781
|
|
|
970
|
-
# Annotates the usage with the RESOLVED model-selection source
|
|
1782
|
+
# Annotates the usage with the RESOLVED model-selection source:
|
|
971
1783
|
# where the model came from (:chat/:agent/:platform_default) travels alongside
|
|
972
1784
|
# the resolved model id (from the provider) into the terminal event/Telemetry,
|
|
973
1785
|
# so billing/telemetry can attribute the turn to a config layer. nil usage
|
|
@@ -994,7 +1806,7 @@ module Insika
|
|
|
994
1806
|
if response.respond_to?(:cached_tokens) && response.cached_tokens
|
|
995
1807
|
usage[:cached_tokens] = response.cached_tokens.to_i # cache_read_input_tokens
|
|
996
1808
|
end
|
|
997
|
-
#
|
|
1809
|
+
# R3: prompt-cache WRITE tokens (Anthropic cache_creation_input_tokens),
|
|
998
1810
|
# billed at ~1.25x. Reported so the first (write) turn vs later (read) turns
|
|
999
1811
|
# are distinguishable in telemetry/usage.
|
|
1000
1812
|
if response.respond_to?(:cache_creation_tokens) && response.cache_creation_tokens
|
|
@@ -1022,7 +1834,7 @@ module Insika
|
|
|
1022
1834
|
|
|
1023
1835
|
def build_context_request(task, profile, state, resume_from)
|
|
1024
1836
|
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
|
|
1837
|
+
state.session = session # create_chat reads it for the per-chat model pin
|
|
1026
1838
|
hist = command_history(task)
|
|
1027
1839
|
# `vars` reconciles the seam (the Request/Session provider already
|
|
1028
1840
|
# called request.vars): session metadata + the explicit `history` in the
|
|
@@ -1031,12 +1843,14 @@ module Insika
|
|
|
1031
1843
|
vars["history"] = hist if hist
|
|
1032
1844
|
# The single type is Insika::ContextRequest (Data); the explicit `history`
|
|
1033
1845
|
# travels in vars["history"] (Session provider convention), not in a field
|
|
1034
|
-
# of its own.
|
|
1846
|
+
# of its own. `memory_scope` is the WS8 customer cell (nil = the providers
|
|
1847
|
+
# fall back to tenant || session, today's behavior).
|
|
1035
1848
|
ContextRequest.new(profile: profile, message: state.message, session: session,
|
|
1036
|
-
checkpoint: resume_from, tenant: command_tenant(task), vars: vars
|
|
1849
|
+
checkpoint: resume_from, tenant: command_tenant(task), vars: vars,
|
|
1850
|
+
memory_scope: memory_tenant(task))
|
|
1037
1851
|
end
|
|
1038
1852
|
|
|
1039
|
-
#
|
|
1853
|
+
# task_started payload. Carries the EXPLICIT command tenant so
|
|
1040
1854
|
# the observability convention can group by it — the one operator-set label that
|
|
1041
1855
|
# is not derivable from the task itself. Omitted when absent: the terminal
|
|
1042
1856
|
# events keep their shape and no consumer sees a null it never saw before. NOT
|
|
@@ -1062,44 +1876,96 @@ module Insika
|
|
|
1062
1876
|
Coercion.presence(rebuild_command(task).payload["origin"])
|
|
1063
1877
|
end
|
|
1064
1878
|
|
|
1065
|
-
# Engine memory scope
|
|
1879
|
+
# Engine memory scope: the Command's EXPLICIT tenant wins (multi-merchant
|
|
1066
1880
|
# override); otherwise the SESSION (=chat) — engine-owner memory is per-chat.
|
|
1067
1881
|
# Symmetric to the READ path (Memory provider). One-shot with no tenant -> nil
|
|
1068
1882
|
# (_default). It is NOT the <request_context> tenant (that follows
|
|
1069
1883
|
# command_tenant, prompt parity) — only the memory read/write scope.
|
|
1884
|
+
#
|
|
1885
|
+
# WS8: a request carrying a CUSTOMER moves the scope to the customer cell —
|
|
1886
|
+
# "[tenant:]customer" when a tenant is present, the bare customer otherwise
|
|
1887
|
+
# (never _default — a tagged customer must never land in the shared cell).
|
|
1888
|
+
# Per-customer memory is the 360 view; per-tenant was the leak.
|
|
1889
|
+
#
|
|
1890
|
+
# the SESSION fallback is MARKED ("chat:<session id>" -> cell
|
|
1891
|
+
# "memory:chat:<session id>"), never a bare cell — a bare "memory:<id>" is
|
|
1892
|
+
# indistinguishable from a single-tenant customer ref, and the Studio drill
|
|
1893
|
+
# must not list conversations as customers with a Forget button.
|
|
1070
1894
|
def memory_tenant(task)
|
|
1071
|
-
|
|
1895
|
+
customer = command_customer(task)
|
|
1896
|
+
return command_tenant(task) || session_scope(task.session_id) if customer.nil?
|
|
1897
|
+
|
|
1898
|
+
[command_tenant(task), customer].compact.join(":")
|
|
1899
|
+
end
|
|
1900
|
+
|
|
1901
|
+
# The marked per-session scope : "chat:<session id>" -> cell
|
|
1902
|
+
# "memory:chat:<session id>". nil for a one-shot turn (no session) — the
|
|
1903
|
+
# MemoryStore applies _default.
|
|
1904
|
+
def session_scope(session_id)
|
|
1905
|
+
return nil if session_id.nil?
|
|
1906
|
+
|
|
1907
|
+
"#{MemoryStore::SESSION_TAG}:#{session_id}"
|
|
1908
|
+
end
|
|
1909
|
+
|
|
1910
|
+
# WS8 + : stamp the customer (WS8 — the `forget_customer`
|
|
1911
|
+
# purge finds the customer's sessions through this var) AND the agent (the
|
|
1912
|
+
# distillation engine resolves each session's pack through it) on the
|
|
1913
|
+
# session ONCE (idempotent). A session that does not exist yet (no
|
|
1914
|
+
# session_id on the turn) is skipped; a look-up failure never breaks the
|
|
1915
|
+
# turn.
|
|
1916
|
+
def stamp_customer_session(task, profile)
|
|
1917
|
+
customer = command_customer(task)
|
|
1918
|
+
return if customer.nil? || task.session_id.nil?
|
|
1919
|
+
|
|
1920
|
+
session = @session_store&.find(task.session_id)
|
|
1921
|
+
return if session.nil? || !Coercion.presence(session.vars["customer"]).nil?
|
|
1922
|
+
|
|
1923
|
+
@session_store.update_vars(task.session_id,
|
|
1924
|
+
"customer" => customer, "agent" => profile.id)
|
|
1925
|
+
rescue Insika::NotFoundError, ArgumentError
|
|
1926
|
+
nil
|
|
1927
|
+
end
|
|
1928
|
+
|
|
1929
|
+
# The optional customer_key on the command payload (WS8): a String identifying
|
|
1930
|
+
# the person the conversation belongs to — the memory scope's customer half
|
|
1931
|
+
# and the handle `forget_customer` purges by. nil = untagged conversation
|
|
1932
|
+
# (memory stays per-tenant/per-chat, byte-identical to before).
|
|
1933
|
+
def command_customer(task)
|
|
1934
|
+
Coercion.presence(rebuild_command(task).payload["customer"])
|
|
1072
1935
|
end
|
|
1073
1936
|
|
|
1074
|
-
# Turn context
|
|
1937
|
+
# Turn context: the ids the data-tools resolve via
|
|
1075
1938
|
# {{ctx.*}} to emit X-Chat-Id/X-Store-Id/X-Agent-Id to /api/internal/*. They
|
|
1076
1939
|
# come from the TURN, never from the model args (R2). chat_id = the session
|
|
1077
1940
|
# (the /v1/responses adapter creates the session with id = user = chat.id);
|
|
1078
1941
|
# tenant = the Command tenant (memory) OR chat_id (drop-in default); agent_id =
|
|
1079
1942
|
# profile; store_id = the profile metadata (stable per store, from the pack).
|
|
1080
1943
|
# Absent fields -> nil (the data-tool emits an empty header; in the pilot the
|
|
1081
|
-
# profile carries store_id). Generic: nothing here mentions
|
|
1944
|
+
# profile carries store_id). Generic: nothing here mentions a consumer.
|
|
1082
1945
|
def build_turn_context(task, profile, state)
|
|
1083
1946
|
{
|
|
1084
1947
|
chat_id: task.session_id,
|
|
1085
1948
|
agent_id: profile.id,
|
|
1086
|
-
|
|
1949
|
+
# the DATA-TOOL header tenant stays the merchant (or the chat), even when
|
|
1950
|
+
# the memory scope carries a customer — the backend identifies the store,
|
|
1951
|
+
# not the shopper (WS8 keeps the two scopes separate).
|
|
1952
|
+
tenant: command_tenant(task) || task.session_id,
|
|
1087
1953
|
store_id: profile.store_id,
|
|
1088
|
-
#
|
|
1954
|
+
# current delegation depth (0 for a top-level turn). Carried in
|
|
1089
1955
|
# the child command's payload by run_subagent; read here so the child's OWN
|
|
1090
1956
|
# spawn_subagent tool sees depth+1 and the runtime cap holds down the chain.
|
|
1091
1957
|
delegation_depth: delegation_depth(task)
|
|
1092
1958
|
}
|
|
1093
1959
|
end
|
|
1094
1960
|
|
|
1095
|
-
# Delegation depth of THIS turn
|
|
1961
|
+
# Delegation depth of THIS turn: the value run_subagent stamped in
|
|
1096
1962
|
# the child command, or 0 for a top-level turn. Integer-coerced (JSON round-trip
|
|
1097
1963
|
# of the persisted command may deliver a String).
|
|
1098
1964
|
def delegation_depth(task)
|
|
1099
1965
|
rebuild_command(task).payload["delegation_depth"].to_i
|
|
1100
1966
|
end
|
|
1101
1967
|
|
|
1102
|
-
#
|
|
1968
|
+
# R2: environment (model/thinking) inherits as DEFAULT — the child's
|
|
1103
1969
|
# explicit value wins; when absent, seed from the parent's RESOLVED selection.
|
|
1104
1970
|
# Capacity fields are untouched (R1: the child profile is used as-is). Returns
|
|
1105
1971
|
# the child profile unchanged when there is nothing to inherit.
|
|
@@ -1120,7 +1986,7 @@ module Insika
|
|
|
1120
1986
|
child_profile.with(model: model, provider: provider, params: params)
|
|
1121
1987
|
end
|
|
1122
1988
|
|
|
1123
|
-
# Single validation path for a delegation
|
|
1989
|
+
# Single validation path for a delegation — shared by run_subagent
|
|
1124
1990
|
# and the fan-out run_subagents. `task` is {agent, message} (string OR symbol
|
|
1125
1991
|
# keys — the model's args arrive string-keyed). Returns a resolved plan
|
|
1126
1992
|
# { agent:, profile:, message:, depth: } or { agent:, error: } (the agent name is
|
|
@@ -1193,7 +2059,7 @@ module Insika
|
|
|
1193
2059
|
[child_session_id, child_task]
|
|
1194
2060
|
end
|
|
1195
2061
|
|
|
1196
|
-
# SYNC
|
|
2062
|
+
# SYNC: spawns the child and AWAITS it on the parent's fiber, then
|
|
1197
2063
|
# projects the terminal content. Direct `spawn` (not spawn_in_session): the
|
|
1198
2064
|
# child session is brand-new, so there is no SessionActor contention — the child
|
|
1199
2065
|
# is parented at turn_parent and the parent yields cooperatively on `wait`.
|
|
@@ -1204,7 +2070,7 @@ module Insika
|
|
|
1204
2070
|
project_child_result(child_task.id, child_session_id, child_profile.id, parent_state)
|
|
1205
2071
|
end
|
|
1206
2072
|
|
|
1207
|
-
# ASYNC
|
|
2073
|
+
# ASYNC: persists a Delegation, spawns the child NON-blocking, and
|
|
1208
2074
|
# returns a dispatch ack immediately — the parent turn ends without waiting. The
|
|
1209
2075
|
# child's terminal hook (finalize_delegation) delivers the result later, as a
|
|
1210
2076
|
# NEW turn on the parent session.
|
|
@@ -1253,7 +2119,7 @@ module Insika
|
|
|
1253
2119
|
exec.error && (exec.error["message"] || exec.error[:message])
|
|
1254
2120
|
end
|
|
1255
2121
|
|
|
1256
|
-
#
|
|
2122
|
+
# terminal hook: when a turn ends (success OR failure), if the
|
|
1257
2123
|
# task is the child of an ASYNC delegation, capture its result and deliver it to
|
|
1258
2124
|
# the parent. Fires for both a normal completion and a resumed one (recovery),
|
|
1259
2125
|
# so it needs no live watcher fiber. No-op without a delegation_store or when the
|
|
@@ -1337,12 +2203,14 @@ module Insika
|
|
|
1337
2203
|
command: rebuild_command(task),
|
|
1338
2204
|
context: state.context,
|
|
1339
2205
|
candidate_tools: @tool_registry.entries,
|
|
1340
|
-
|
|
2206
|
+
# agent: so a specialized skill reaches the policy as the agent's own version
|
|
2207
|
+
# (same name, its body) instead of the shared one it overrides.
|
|
2208
|
+
candidate_skills: @skill_catalog.effective(profile.skills, agent: profile.id)
|
|
1341
2209
|
)
|
|
1342
2210
|
end
|
|
1343
2211
|
|
|
1344
2212
|
# The Task persists the Command as a Hash; the WorkflowAllowlist needs
|
|
1345
|
-
# a Command with #type (Symbol) and #payload
|
|
2213
|
+
# a Command with #type (Symbol) and #payload.: the SINGLE point that
|
|
1346
2214
|
# reconciles the string||symbol keys of the persisted command — payload/meta
|
|
1347
2215
|
# keys are stringified ONCE here, so every reader (command_type/workflow_name/
|
|
1348
2216
|
# extract_message/command_history/command_tenant) works with string keys.
|
|
@@ -1362,7 +2230,7 @@ module Insika
|
|
|
1362
2230
|
(hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
1363
2231
|
end
|
|
1364
2232
|
|
|
1365
|
-
# Stage-3-tail tool assembly — delegated to ToolAssembly
|
|
2233
|
+
# Stage-3-tail tool assembly — delegated to ToolAssembly. Kept as
|
|
1366
2234
|
# thin private methods so the existing spec contract (executor.send(:...))
|
|
1367
2235
|
# stays intact and run_pipeline reads unchanged.
|
|
1368
2236
|
def resolve_capabilities(profile, context) = @tool_assembly.resolve_capabilities(profile, context)
|
|
@@ -1403,13 +2271,13 @@ module Insika
|
|
|
1403
2271
|
))
|
|
1404
2272
|
end
|
|
1405
2273
|
|
|
1406
|
-
# GRACEFUL halt
|
|
2274
|
+
# GRACEFUL halt: a Middleware short-circuited with a safe reply.
|
|
1407
2275
|
# The turn COMPLETES — same stages 8-9 as a normal turn — but the "assistant
|
|
1408
2276
|
# content" is the guardrail's safe response, produced with ZERO LLM calls. The
|
|
1409
2277
|
# order mirrors a real turn so both the /v1/responses consumer (which reads the
|
|
1410
2278
|
# text off :content deltas) and the Studio viewer render it: audit -> safe text
|
|
1411
2279
|
# -> persist -> terminal.
|
|
1412
|
-
def complete_with_halt(task, profile, state)
|
|
2280
|
+
def complete_with_halt(task, profile, state, timing = nil)
|
|
1413
2281
|
content = state.halt_response.to_s
|
|
1414
2282
|
state.response_content = content
|
|
1415
2283
|
|
|
@@ -1420,39 +2288,60 @@ module Insika
|
|
|
1420
2288
|
}, task: task)
|
|
1421
2289
|
end
|
|
1422
2290
|
emit(:content, { delta: content }, task: task) unless content.empty?
|
|
1423
|
-
# An EDGE-blocked turn (rate limit / token ceiling
|
|
2291
|
+
# An EDGE-blocked turn (rate limit / token ceiling) completes but
|
|
1424
2292
|
# stays OUT of the session history: a flood at the wall must not bloat the
|
|
1425
2293
|
# session nor evict real conversation from the context budget — the
|
|
1426
2294
|
# :guardrail_blocked event is the audit trail. Content-guardrail blocks
|
|
1427
|
-
# keep persisting (
|
|
2295
|
+
# keep persisting (the refusal is part of the conversation).
|
|
1428
2296
|
# The reply is the guardrail's, produced with zero LLM calls — so it is NOT the
|
|
1429
2297
|
# agent talking, and a report that counts it as the agent repeating itself is
|
|
1430
2298
|
# reading the engine's own canned text (the `safe_reply` finding exists exactly
|
|
1431
2299
|
# because that text is otherwise indistinguishable in the transcript).
|
|
1432
2300
|
persist_turn(task, profile, state, content, reply_origin: MessageOrigin::ENGINE,
|
|
1433
|
-
session: state.guardrail_block&.[](:source) != "edge")
|
|
1434
|
-
|
|
2301
|
+
session: state.guardrail_block&.[](:source) != "edge", timing: timing)
|
|
2302
|
+
data = { task_id: task.id, content: content, usage: state.usage }
|
|
2303
|
+
data[:timing] = timing.to_h if timing # a channel halt still measured
|
|
2304
|
+
persist_turn_timing(task, timing)
|
|
2305
|
+
emit(:task_completed, data, task: task)
|
|
1435
2306
|
end
|
|
1436
2307
|
|
|
1437
|
-
#
|
|
1438
|
-
#
|
|
1439
|
-
#
|
|
1440
|
-
|
|
1441
|
-
|
|
2308
|
+
# Best-effort write of the turn's timing onto the task record .
|
|
2309
|
+
# The record gains `timing` once, when the turn completes; a store failure
|
|
2310
|
+
# here is swallowed — the turn is already committed and the event already
|
|
2311
|
+
# carries the number.
|
|
2312
|
+
def persist_turn_timing(task, timing)
|
|
2313
|
+
return unless timing
|
|
1442
2314
|
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
2315
|
+
hash = timing.to_h
|
|
2316
|
+
return if hash.empty?
|
|
2317
|
+
|
|
2318
|
+
@task_store.record_timing(task.id, hash)
|
|
2319
|
+
rescue Insika::Error
|
|
2320
|
+
nil
|
|
1448
2321
|
end
|
|
1449
2322
|
|
|
2323
|
+
# Emits one :guardrail_flagged per flag the OutputValidator appended in
|
|
2324
|
+
# after_task (audit only — the turn already completed). Reads a plain Array off
|
|
2325
|
+
# the state, keeping the Executor decoupled from Safety. an
|
|
2326
|
+
# :enforce cut rides the SAME event with `action: "cut"` so the audit can
|
|
2327
|
+
# distinguish a cut from a flag.
|
|
2328
|
+
def emit_guardrail_flags(task, state)
|
|
2329
|
+
return unless state.respond_to?(:guardrail_flags)
|
|
2330
|
+
|
|
2331
|
+
Array(state.guardrail_flags).each do |flag|
|
|
2332
|
+
data = { task_id: task.id, category: flag[:category], source: flag[:source],
|
|
2333
|
+
detail: flag[:detail] }
|
|
2334
|
+
data[:action] = flag[:action] if flag[:action]
|
|
2335
|
+
emit(:guardrail_flagged, data, task: task)
|
|
2336
|
+
end
|
|
2337
|
+
end
|
|
2338
|
+
|
|
1450
2339
|
# Stage 8: FIXED order checkpoint -> session -> task. If it crashes
|
|
1451
2340
|
# between writes, the worst case is a new checkpoint with the task :running ->
|
|
1452
2341
|
# Recovery re-executes the already-saved turn (safe thanks to the side-effect
|
|
1453
2342
|
# recording).
|
|
1454
2343
|
#
|
|
1455
|
-
# CHECKPOINT vs SESSION — the two stores DIVERGE by design (
|
|
2344
|
+
# CHECKPOINT vs SESSION — the two stores DIVERGE by design (R2c):
|
|
1456
2345
|
# · Checkpoint.messages = flatten_history(context.history) + new_messages,
|
|
1457
2346
|
# i.e. "what the model actually SAW this turn" AFTER budget eviction
|
|
1458
2347
|
# (context.history is the post-budget assembly). It is the deterministic
|
|
@@ -1463,7 +2352,7 @@ module Insika
|
|
|
1463
2352
|
# So a long session legitimately has a Checkpoint SHORTER than the Session:
|
|
1464
2353
|
# that is not drift to reconcile — it is the point. Do NOT "fix" the checkpoint
|
|
1465
2354
|
# to carry the full history (it would defeat the budget) nor evict the session.
|
|
1466
|
-
def persist_turn(task, profile, state, content, session: true, reply_origin: nil)
|
|
2355
|
+
def persist_turn(task, profile, state, content, session: true, reply_origin: nil, timing: nil)
|
|
1467
2356
|
new_messages = turn_transcript(state, content, origin: command_origin(task), reply_origin: reply_origin)
|
|
1468
2357
|
transcript = flatten_history(state.context.history) + new_messages
|
|
1469
2358
|
|
|
@@ -1493,22 +2382,22 @@ module Insika
|
|
|
1493
2382
|
|
|
1494
2383
|
emit(:checkpoint_created, { task_id: task.id, turn: state.turn + 1 }, task: task)
|
|
1495
2384
|
|
|
1496
|
-
#
|
|
2385
|
+
# if this completed turn is an ASYNC delegation child,
|
|
1497
2386
|
# deliver its result to the parent as a NEW turn. No-op for a normal turn
|
|
1498
2387
|
# (not a delegation child) or without a delegation_store.
|
|
1499
2388
|
finalize_delegation(task)
|
|
1500
2389
|
|
|
1501
|
-
#
|
|
2390
|
+
# if this turn CAME IN through a Shape B channel, its answer
|
|
1502
2391
|
# has to travel out of band. Same terminal hook, next door to the delegation
|
|
1503
2392
|
# one, for the same reason: it fires for a fresh turn and a recovered one.
|
|
1504
|
-
finalize_channel_delivery(task, content)
|
|
2393
|
+
finalize_channel_delivery(task, content, state, timing)
|
|
1505
2394
|
end
|
|
1506
2395
|
|
|
1507
2396
|
# Records the answer in the outbox and dispatches it. The discriminator is the
|
|
1508
2397
|
# turn's TRANSPORT (`channel:<id>` on the persisted command), not the session:
|
|
1509
2398
|
# a session belongs to the channel forever, but a message an operator types into
|
|
1510
2399
|
# the Studio playground against that same session must not reach the customer.
|
|
1511
|
-
# Human handoff is not a product feature (
|
|
2400
|
+
# Human handoff is not a product feature (``), and it would be a
|
|
1512
2401
|
# surprising way to acquire one.
|
|
1513
2402
|
#
|
|
1514
2403
|
# The consequence, stated rather than discovered later: a turn the ENGINE
|
|
@@ -1518,31 +2407,44 @@ module Insika
|
|
|
1518
2407
|
#
|
|
1519
2408
|
# Best-effort: the turn is already committed and durable, and a delivery problem
|
|
1520
2409
|
# must never re-fail it.
|
|
1521
|
-
|
|
2410
|
+
#
|
|
2411
|
+
# a PROGRESSIVE channel gets the answer split into balloons —
|
|
2412
|
+
# N outbox rows, dispatched in index order (dispatch_chain). `:at_end` is the
|
|
2413
|
+
# single whole-answer row, byte-identical to today.
|
|
2414
|
+
def finalize_channel_delivery(task, content, state, timing = nil)
|
|
1522
2415
|
return unless @channel_delivery
|
|
1523
2416
|
|
|
1524
2417
|
channel_id = channel_transport(task)
|
|
1525
2418
|
return unless channel_id
|
|
1526
2419
|
|
|
1527
|
-
|
|
1528
|
-
|
|
2420
|
+
# the hoarded evidence attachments ride the channel delivery
|
|
2421
|
+
# (additive outbox payload key — the channel contract widens, nothing breaks).
|
|
2422
|
+
attachments = state.respond_to?(:evidence_attachments) ? state.evidence_attachments : nil
|
|
2423
|
+
deliveries = @channel_delivery.record_balloons(
|
|
2424
|
+
task: task, channel_id: channel_id, content: content,
|
|
2425
|
+
progressive: @channel_delivery.progressive?(channel_id),
|
|
2426
|
+
attachments: attachments
|
|
2427
|
+
)
|
|
2428
|
+
return if deliveries.empty?
|
|
1529
2429
|
|
|
1530
|
-
|
|
2430
|
+
timing&.mark(:first_balloon) # C5: inbound -> first outbox row, first-write-wins
|
|
2431
|
+
dispatch_chain(deliveries.map(&:id))
|
|
1531
2432
|
rescue Insika::Error
|
|
1532
2433
|
nil
|
|
1533
2434
|
end
|
|
1534
2435
|
|
|
1535
|
-
#
|
|
1536
|
-
#
|
|
1537
|
-
# customer's next message
|
|
1538
|
-
# Non-serving (boot sweep, specs) delivers inline, where waiting is
|
|
1539
|
-
# caller wants.
|
|
1540
|
-
def
|
|
1541
|
-
|
|
2436
|
+
# ONE supervisor fiber for the whole chain. Sequential deliver
|
|
2437
|
+
# calls, so balloon N+1 cannot overtake balloon N on the wire. Still off the
|
|
2438
|
+
# session's FIFO — the customer's next message does not wait on this turn's
|
|
2439
|
+
# outbound. Non-serving (boot sweep, specs) delivers inline, where waiting is
|
|
2440
|
+
# what the caller wants.
|
|
2441
|
+
def dispatch_chain(ids)
|
|
2442
|
+
run = lambda { ids.each { |id| @channel_delivery.deliver(id) } }
|
|
2443
|
+
return run.call unless @supervised
|
|
1542
2444
|
|
|
1543
2445
|
turn_parent.async do |t|
|
|
1544
|
-
t.annotate("outbox:#{
|
|
1545
|
-
|
|
2446
|
+
t.annotate("outbox:#{ids.first}")
|
|
2447
|
+
run.call
|
|
1546
2448
|
end
|
|
1547
2449
|
end
|
|
1548
2450
|
|
|
@@ -1553,15 +2455,15 @@ module Insika
|
|
|
1553
2455
|
transport.start_with?("channel:") ? transport.delete_prefix("channel:") : nil
|
|
1554
2456
|
end
|
|
1555
2457
|
|
|
1556
|
-
# Truncation cap for a persisted `role: tool` content (
|
|
2458
|
+
# Truncation cap for a persisted `role: tool` content (R1): the transcript
|
|
1557
2459
|
# keeps the loop coherent; the FULL result lives in the ToolTraceStore (viewer).
|
|
1558
2460
|
TOOL_CONTENT_CAP = 4_000
|
|
1559
2461
|
|
|
1560
|
-
# The turn's messages in the ADDITIVE string-keyed format (
|
|
2462
|
+
# The turn's messages in the ADDITIVE string-keyed format (R1). Prefers the
|
|
1561
2463
|
# real chat transcript (`chat.messages.drop(baseline)`) so tool calls/results
|
|
1562
2464
|
# survive between turns; falls back to the {user, assistant} pair when the chat
|
|
1563
2465
|
# did not record the turn (workflow, graceful halt, or the specs' FakeChat).
|
|
1564
|
-
# The final assistant text is the REDACTED `content` (output_filter
|
|
2466
|
+
# The final assistant text is the REDACTED `content` (output_filter),
|
|
1565
2467
|
# never the raw text the gem stored.
|
|
1566
2468
|
# `origin` (MessageOrigin) travels on the turn's Command and is stamped on the
|
|
1567
2469
|
# message it describes: the INCOMING one. It is absent for an ordinary turn, and
|
|
@@ -1637,7 +2539,7 @@ module Insika
|
|
|
1637
2539
|
end
|
|
1638
2540
|
|
|
1639
2541
|
# context.history may carry "eviction units" (an assistant+tool_results cycle
|
|
1640
|
-
# grouped as one Array by the Session provider,
|
|
2542
|
+
# grouped as one Array by the Session provider, R1). Checkpoints store a
|
|
1641
2543
|
# FLAT list — the provider regroups on read. Flatten one level; message Hashes
|
|
1642
2544
|
# are untouched.
|
|
1643
2545
|
def flatten_history(history) = Array(history).flatten(1)
|
|
@@ -1653,28 +2555,58 @@ module Insika
|
|
|
1653
2555
|
require_relative "tools/remember"
|
|
1654
2556
|
require_relative "tools/subagent"
|
|
1655
2557
|
require_relative "tools/subagents"
|
|
1656
|
-
|
|
2558
|
+
require_relative "tools/stuck_signal"
|
|
2559
|
+
require_relative "tools/generate_image"
|
|
2560
|
+
require_relative "tools/tts"
|
|
2561
|
+
require_relative "tools/update_briefing"
|
|
2562
|
+
# the schedule/cancel_followup builtins — lazy, same
|
|
2563
|
+
# boundary (the ChatBuilder wires them only when a profile declares
|
|
2564
|
+
# followup AND the stores are present).
|
|
2565
|
+
require_relative "tools/schedule_followup"
|
|
2566
|
+
# v2 resolution: Chat pin > Agent model > platform default, model_policy
|
|
1657
2567
|
# enforced, fallback chain resolved. Kept on the state for telemetry (usage).
|
|
1658
2568
|
selection = @model_resolver.resolve(profile: profile, session: state.session)
|
|
1659
2569
|
state.model_selection = selection
|
|
2570
|
+
build_chat(selection, selection)
|
|
2571
|
+
end
|
|
2572
|
+
|
|
2573
|
+
# The gem boundary: one chat for a model selection (the resolved primary or
|
|
2574
|
+
# a WS3 fallback node). The primary's generation params apply to the whole
|
|
2575
|
+
# chain (params_source: ModelSelection#apply_params).
|
|
2576
|
+
def build_chat(selection, params_source)
|
|
2577
|
+
model = selection.respond_to?(:model) ? selection.model : selection[:model]
|
|
2578
|
+
provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
|
|
1660
2579
|
chat = (@llm || RubyLLM).chat(
|
|
1661
|
-
model:
|
|
1662
|
-
provider:
|
|
1663
|
-
assume_model_exists:
|
|
2580
|
+
model: model,
|
|
2581
|
+
provider: provider,
|
|
2582
|
+
assume_model_exists: !provider.nil?
|
|
1664
2583
|
)
|
|
1665
|
-
|
|
2584
|
+
params_source.apply_params(chat) # temperature/max_tokens/thinking (per-agent)
|
|
1666
2585
|
chat
|
|
1667
2586
|
end
|
|
1668
2587
|
|
|
1669
2588
|
# Single emitter: an Event with meta and a monotonic seq per task. @seqs is not
|
|
1670
2589
|
# cleared at the end of the task — the resume (new Execution) continues the
|
|
1671
|
-
# numbering (reliable replay).
|
|
2590
|
+
# numbering (reliable replay). A task WITH a tenant (WS1) tags every event it
|
|
2591
|
+
# emits — the tenant-scoped /v1/events subscription filters on it (a control
|
|
2592
|
+
# event without a task has no tenant and never matches a tenant stream);
|
|
2593
|
+
# absent tenant -> the meta is byte-identical to before.
|
|
1672
2594
|
def emit(type, data, task:)
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
2595
|
+
meta = { task_id: task.id, session_id: task.session_id,
|
|
2596
|
+
seq: (@seqs[task.id] += 1), at: Time.now.utc.iso8601 }
|
|
2597
|
+
tenant = task_tenant(task)
|
|
2598
|
+
meta[:tenant] = tenant unless tenant.nil?
|
|
2599
|
+
@event_stream.emit(Insika::Event.new(type: type, data: data, meta: meta))
|
|
2600
|
+
end
|
|
2601
|
+
|
|
2602
|
+
# The tenant stamped on the task's command (WS1), nil when the request was
|
|
2603
|
+
# operator-made. Cheap read on the persisted command hash — never rebuilds.
|
|
2604
|
+
def task_tenant(task)
|
|
2605
|
+
command = task.respond_to?(:command) ? task.command : nil
|
|
2606
|
+
return nil unless command.is_a?(Hash)
|
|
2607
|
+
|
|
2608
|
+
meta = command["meta"] || command[:meta] || {}
|
|
2609
|
+
meta["tenant"] || meta[:tenant]
|
|
1678
2610
|
end
|
|
1679
2611
|
end
|
|
1680
2612
|
end
|