insika 0.8.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +312 -2
- data/README.md +10 -6
- data/bin/insika +4 -1
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +142 -18
- data/docs/API.md +73 -0
- data/docs/ARCHITECTURE.md +56 -50
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CHANNELS.md +19 -2
- data/docs/CONTEXT.md +96 -27
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +70 -13
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +129 -10
- data/docs/FACTS.md +12 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +14 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +164 -28
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +26 -14
- data/docs/RELEASING.md +9 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +34 -1
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +238 -42
- data/docs/operate.md +3 -0
- data/docs/prompts/ADD-TOOL.md +12 -2
- data/docs/prompts/DIAGNOSE-TURN.md +3 -0
- data/docs/prompts/GO-LIVE.md +3 -1
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +99 -17
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/channels/web/widget.js +33 -0
- data/lib/insika/channels/web.rb +5 -2
- data/lib/insika/chat_builder.rb +124 -28
- data/lib/insika/checkpoint.rb +6 -1
- data/lib/insika/checkpoint_store.rb +59 -7
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/delete_kit.rb +26 -0
- data/lib/insika/commands/gate_harvest.rb +3 -0
- data/lib/insika/commands/review_guardrail_flag.rb +38 -0
- data/lib/insika/commands/run_distillation.rb +5 -8
- data/lib/insika/commands/run_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +128 -0
- data/lib/insika/commands/send_message.rb +9 -3
- data/lib/insika/commands/update_settings.rb +23 -1
- data/lib/insika/commands/write_kit.rb +27 -0
- data/lib/insika/compaction.rb +33 -7
- data/lib/insika/config_store.rb +87 -7
- data/lib/insika/context/builder.rb +29 -9
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +7 -2
- data/lib/insika/context/providers/briefing.rb +11 -8
- data/lib/insika/context/providers/fence_notice.rb +27 -0
- data/lib/insika/context/providers/knowledge.rb +32 -6
- data/lib/insika/context/providers/memory.rb +61 -8
- data/lib/insika/context/providers/pending_confirmation.rb +52 -0
- data/lib/insika/context/providers/prompt.rb +17 -4
- data/lib/insika/context/providers/session.rb +46 -6
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +142 -4
- data/lib/insika/dsl/runtime.rb +37 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +19 -5
- data/lib/insika/edge_limiter.rb +27 -16
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/errors.rb +1 -0
- data/lib/insika/evals/assertions.rb +259 -18
- data/lib/insika/evals/golden.rb +230 -4
- data/lib/insika/evals/judge.rb +19 -10
- data/lib/insika/evals/moderator_compare.rb +47 -0
- data/lib/insika/evals/report.rb +61 -9
- data/lib/insika/evals/runner.rb +64 -4
- data/lib/insika/evals/simulator.rb +21 -3
- data/lib/insika/evals/transport.rb +317 -15
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +105 -17
- data/lib/insika/executor.rb +698 -164
- data/lib/insika/fence.rb +96 -0
- data/lib/insika/golden_store.rb +3 -0
- data/lib/insika/harvest.rb +26 -14
- data/lib/insika/harvest_engine.rb +1 -0
- data/lib/insika/harvest_store.rb +12 -0
- data/lib/insika/http_client.rb +87 -4
- data/lib/insika/kits.rb +54 -0
- data/lib/insika/knowledge.rb +34 -20
- data/lib/insika/llm_configurator.rb +46 -4
- data/lib/insika/llm_provider_store.rb +1 -3
- data/lib/insika/llm_trace_store.rb +54 -0
- data/lib/insika/mcp_client.rb +27 -63
- data/lib/insika/mcp_live_tool.rb +55 -9
- data/lib/insika/mcp_store.rb +37 -3
- data/lib/insika/mcp_tool_registry.rb +76 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +16 -3
- data/lib/insika/memory_trim.rb +60 -0
- data/lib/insika/middleware.rb +12 -3
- data/lib/insika/model_metrics_store.rb +285 -0
- data/lib/insika/model_registry.rb +110 -0
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +14 -21
- data/lib/insika/model_visible.rb +17 -6
- data/lib/insika/onboarding.rb +1 -0
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +30 -2
- data/lib/insika/pack_importer.rb +54 -9
- data/lib/insika/packaging.rb +14 -5
- data/lib/insika/pending_action_store.rb +45 -18
- data/lib/insika/plugin/loader.rb +4 -2
- data/lib/insika/prefix_fingerprint.rb +32 -27
- data/lib/insika/profile_source.rb +5 -0
- data/lib/insika/provider_error_classifier.rb +18 -3
- data/lib/insika/queue_policy.rb +7 -2
- data/lib/insika/refinement/proposer.rb +42 -17
- data/lib/insika/reliability.rb +12 -3
- data/lib/insika/router/app.rb +14 -5
- data/lib/insika/router/session_key.rb +35 -5
- data/lib/insika/safety/config.rb +20 -3
- data/lib/insika/safety/corpus.rb +10 -3
- data/lib/insika/safety/factory.rb +39 -1
- data/lib/insika/safety/input_guardrail.rb +21 -1
- data/lib/insika/safety/judge_moderator.rb +70 -0
- data/lib/insika/safety/output_validator.rb +29 -5
- data/lib/insika/safety/prompt_echo.rb +89 -0
- data/lib/insika/schema_guard.rb +53 -76
- data/lib/insika/server/app.rb +58 -7
- data/lib/insika/server/rack_app.rb +2 -0
- data/lib/insika/server/responses.rb +50 -5
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +163 -11
- data/lib/insika/settings_store.rb +57 -2
- data/lib/insika/shared_conversations.rb +305 -0
- data/lib/insika/skill_catalog.rb +28 -2
- data/lib/insika/skill_store.rb +6 -0
- data/lib/insika/soak/runner.rb +1 -1
- data/lib/insika/spoken_transcript.rb +31 -0
- data/lib/insika/steer_injector.rb +7 -48
- data/lib/insika/store.rb +29 -1
- data/lib/insika/stores/json_value.rb +37 -0
- data/lib/insika/stores/memory.rb +22 -35
- data/lib/insika/stores/postgres.rb +295 -0
- data/lib/insika/stores/sqlite.rb +257 -43
- data/lib/insika/stores/turn_counter.rb +25 -0
- data/lib/insika/studio/app.rb +1459 -163
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +25 -25
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- data/lib/insika/studio/assets/dist/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
- data/lib/insika/studio/forms.rb +189 -12
- data/lib/insika/studio/nav_icons.rb +3 -0
- data/lib/insika/studio/views/_agent_config_retrieval.erb +73 -0
- data/lib/insika/studio/views/_agent_picker.erb +15 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +83 -243
- data/lib/insika/studio/views/_agent_tab_history.erb +56 -1
- data/lib/insika/studio/views/_agent_tab_performance.erb +34 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +7 -9
- data/lib/insika/studio/views/_agent_tab_skills.erb +16 -7
- data/lib/insika/studio/views/_agent_tab_tools.erb +90 -0
- data/lib/insika/studio/views/_agents_master.erb +8 -12
- data/lib/insika/studio/views/_cfg_alerts.erb +4 -0
- data/lib/insika/studio/views/_cfg_budget.erb +20 -0
- data/lib/insika/studio/views/_cfg_distill.erb +26 -0
- data/lib/insika/studio/views/_cfg_followup.erb +30 -0
- data/lib/insika/studio/views/_cfg_grounding.erb +21 -0
- data/lib/insika/studio/views/_cfg_guardrails.erb +47 -0
- data/lib/insika/studio/views/_cfg_harvest.erb +35 -0
- data/lib/insika/studio/views/_cfg_reliability.erb +32 -0
- data/lib/insika/studio/views/_chats_master.erb +66 -0
- data/lib/insika/studio/views/_customers_master.erb +37 -0
- data/lib/insika/studio/views/_empty.erb +15 -0
- data/lib/insika/studio/views/_facts_master.erb +42 -0
- data/lib/insika/studio/views/_harvest_candidate_detail.erb +14 -0
- data/lib/insika/studio/views/_identity.erb +16 -0
- data/lib/insika/studio/views/_kpi.erb +17 -0
- data/lib/insika/studio/views/_message.erb +40 -2
- data/lib/insika/studio/views/_model_chart.erb +82 -0
- data/lib/insika/studio/views/_refinement_master.erb +38 -0
- data/lib/insika/studio/views/{tool_edit.erb → _tool_edit.erb} +38 -11
- data/lib/insika/studio/views/_tool_users.erb +17 -0
- data/lib/insika/studio/views/_why.erb +53 -0
- data/lib/insika/studio/views/agent_detail.erb +17 -10
- data/lib/insika/studio/views/agents.erb +28 -16
- data/lib/insika/studio/views/approvals.erb +16 -11
- data/lib/insika/studio/views/artifact.erb +20 -7
- data/lib/insika/studio/views/artifacts.erb +19 -14
- data/lib/insika/studio/views/chats.erb +22 -29
- data/lib/insika/studio/views/customer.erb +88 -43
- data/lib/insika/studio/views/customers.erb +16 -26
- data/lib/insika/studio/views/evals.erb +49 -31
- data/lib/insika/studio/views/fact_detail.erb +114 -0
- data/lib/insika/studio/views/facts.erb +15 -116
- data/lib/insika/studio/views/followups.erb +63 -52
- data/lib/insika/studio/views/funnel.erb +104 -43
- data/lib/insika/studio/views/harvest.erb +85 -59
- data/lib/insika/studio/views/home.erb +138 -23
- data/lib/insika/studio/views/inbox.erb +36 -0
- data/lib/insika/studio/views/kits.erb +176 -0
- data/lib/insika/studio/views/knowledge.erb +92 -80
- data/lib/insika/studio/views/layout.erb +42 -11
- data/lib/insika/studio/views/live.erb +45 -0
- data/lib/insika/studio/views/mcp.erb +43 -13
- data/lib/insika/studio/views/models.erb +187 -0
- data/lib/insika/studio/views/parity.erb +38 -32
- data/lib/insika/studio/views/playground.erb +13 -9
- data/lib/insika/studio/views/refinement.erb +15 -224
- data/lib/insika/studio/views/refinement_detail.erb +243 -0
- data/lib/insika/studio/views/session.erb +153 -118
- data/lib/insika/studio/views/settings.erb +86 -22
- data/lib/insika/studio/views/skills.erb +53 -19
- data/lib/insika/studio/views/system_files.erb +7 -7
- data/lib/insika/studio/views/task.erb +45 -15
- data/lib/insika/studio/views/tasks.erb +35 -18
- data/lib/insika/studio/views/tools.erb +62 -73
- data/lib/insika/task_actor.rb +4 -1
- data/lib/insika/task_store.rb +67 -1
- data/lib/insika/telemetry/pricing.rb +16 -14
- data/lib/insika/telemetry/recorder.rb +115 -9
- data/lib/insika/telemetry/ruby_llm_instrumenter.rb +110 -0
- data/lib/insika/telemetry.rb +12 -2
- data/lib/insika/templates/daily-digest/README.md +13 -0
- data/lib/insika/templates/daily-digest/agent.rb +22 -11
- data/lib/insika/testing/store_contract.rb +65 -0
- data/lib/insika/text_tool_call.rb +66 -0
- data/lib/insika/tick.rb +7 -1
- data/lib/insika/token_store.rb +5 -0
- data/lib/insika/tool_assembly.rb +21 -13
- data/lib/insika/tool_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +82 -13
- data/lib/insika/tool_envelope.rb +266 -29
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +12 -4
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +14 -4
- data/lib/insika/tools/a2a_remote.rb +1 -1
- data/lib/insika/tools/agent_enum.rb +1 -1
- data/lib/insika/tools/confirm_pending.rb +112 -0
- data/lib/insika/tools/data_defined_tool.rb +24 -15
- data/lib/insika/tools/generate_image.rb +2 -2
- data/lib/insika/tools/load_knowledge.rb +7 -3
- data/lib/insika/tools/load_skill.rb +3 -3
- data/lib/insika/tools/present.rb +115 -0
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +9 -6
- data/lib/insika/tools/save_artifact.rb +16 -6
- data/lib/insika/tools/schedule_followup.rb +3 -3
- data/lib/insika/tools/stuck_signal.rb +3 -3
- data/lib/insika/tools/subagent.rb +5 -5
- data/lib/insika/tools/subagents.rb +3 -3
- data/lib/insika/tools/tool_search.rb +11 -7
- data/lib/insika/tools/tts.rb +2 -2
- data/lib/insika/tools/update_briefing.rb +4 -4
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +31 -1
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +88 -16
- data/lib/insika/wiring/graph_chat.rb +4 -0
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +23 -3
- metadata +74 -13
- data/lib/insika/commands/import_mcp_tools.rb +0 -48
- data/lib/insika/mcp_http_client.rb +0 -67
- data/lib/insika/mcp_tool_ingestor.rb +0 -165
data/lib/insika/executor.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "time"
|
|
4
4
|
require "securerandom"
|
|
5
5
|
require "async/queue"
|
|
6
|
+
require_relative "telemetry/pricing"
|
|
6
7
|
|
|
7
8
|
module Insika
|
|
8
9
|
# Coordinates execution. It does not build context, decide policy, or talk to
|
|
@@ -18,12 +19,15 @@ module Insika
|
|
|
18
19
|
session_store:, task_store:, checkpoint_store:,
|
|
19
20
|
event_stream:, workflow_registry: nil, pending_action_store: nil,
|
|
20
21
|
capability_registry: nil, tool_catalog: nil, memory_store: nil,
|
|
21
|
-
tool_trace_store: nil, settings_store: nil, content_filter_factory: nil,
|
|
22
|
+
tool_trace_store: nil, llm_trace_store: nil, model_metrics_store: nil, settings_store: nil, content_filter_factory: nil,
|
|
22
23
|
delegation_store: nil, channel_delivery: nil, llm: nil,
|
|
23
24
|
context_trace_store: nil, reliability: nil, media: nil, media_output: nil,
|
|
24
25
|
grounding_enforcer: nil, cache_series_store: nil,
|
|
25
26
|
contact_store: nil, followup_store: nil, model_visible_trace_store: nil,
|
|
26
|
-
knowledge_store: nil)
|
|
27
|
+
knowledge_store: nil, shared_conversations: nil, llm_refresh: nil, pricing: nil)
|
|
28
|
+
@pricing = pricing || Telemetry::Pricing.from_env
|
|
29
|
+
@shared_conversations = shared_conversations
|
|
30
|
+
@shared_turns = {}
|
|
27
31
|
@context_builder = context_builder
|
|
28
32
|
@policy_engine = policy_engine
|
|
29
33
|
@middleware = middleware
|
|
@@ -39,6 +43,10 @@ module Insika
|
|
|
39
43
|
@workflow_registry = workflow_registry # stage 6 of trigger_workflow
|
|
40
44
|
@pending_action_store = pending_action_store # approval gate
|
|
41
45
|
@capability_registry = capability_registry # capability resolution (nil = off)
|
|
46
|
+
@llm_trace_store = llm_trace_store
|
|
47
|
+
@model_metrics_store = model_metrics_store
|
|
48
|
+
# task id -> the running turn's LLM diagnostics, written once when it ends
|
|
49
|
+
@llm_buffers = {}
|
|
42
50
|
@tool_trace_store = tool_trace_store # tool-call trace for Studio debugging (nil = off)
|
|
43
51
|
# per-turn context breakdown (tokens by category + budget) for the
|
|
44
52
|
# Studio session card. nil = off (no record, zero overhead — parity).
|
|
@@ -67,6 +75,10 @@ module Insika
|
|
|
67
75
|
# process-wide RubyLLM constant (the historic single-graph deployment).
|
|
68
76
|
# Duck-typed: Context#chat and RubyLLM.chat take the same keywords.
|
|
69
77
|
@llm = llm
|
|
78
|
+
# ->() re-applies the LLM providers authored at runtime when they changed
|
|
79
|
+
# (LLMConfigurator#refresh), so a key stored through another worker reaches
|
|
80
|
+
# this one before its next chat. nil = credentials fixed at boot.
|
|
81
|
+
@llm_refresh = llm_refresh
|
|
70
82
|
# stability for the turn's single agent interaction (WS3): retries /
|
|
71
83
|
# fallback / circuit breaker, all DATA on AgentProfile#reliability.
|
|
72
84
|
# nil = the plain single ask (parity).
|
|
@@ -184,6 +196,10 @@ module Insika
|
|
|
184
196
|
# scans, nothing mines).
|
|
185
197
|
attr_accessor :harvest_engine
|
|
186
198
|
|
|
199
|
+
# Insika::MemoryTrim: hands freed malloc memory back to the OS when the worker
|
|
200
|
+
# goes quiet (a child of the turn supervisor, like the tick). nil = off.
|
|
201
|
+
attr_accessor :memory_trim
|
|
202
|
+
|
|
187
203
|
# Forces the long-lived turn supervisor (and its tick/alert/distill/harvest
|
|
188
204
|
# children — see #turn_parent) to start NOW instead of lazily on the first
|
|
189
205
|
# served turn. Without this, a deployment whose only agents are scheduled
|
|
@@ -255,6 +271,22 @@ module Insika
|
|
|
255
271
|
!actor.nil?
|
|
256
272
|
end
|
|
257
273
|
|
|
274
|
+
# RubyLLM may query this repeatedly while partitioning pending tools.
|
|
275
|
+
# Only the persisted, exactly matching operator decision authorizes a call.
|
|
276
|
+
def approval_decision(task:, turn:, tool:, args:, call_id:)
|
|
277
|
+
raise Insika::Error, "PendingActionStore is not configured" unless @pending_action_store
|
|
278
|
+
raise Insika::ValidationError, "approval requires a tool call id" if call_id.to_s.empty?
|
|
279
|
+
|
|
280
|
+
action = @pending_action_store.find(native_pending_id(task.id, turn, call_id))
|
|
281
|
+
return nil unless action
|
|
282
|
+
|
|
283
|
+
unless action.kind == PendingActionStore::OPERATOR && action.task_id == task.id.to_s &&
|
|
284
|
+
action.turn == turn && action.tool == tool.to_s && action.args == Coercion.deep_stringify(args || {})
|
|
285
|
+
raise Insika::ValidationError, "approval does not match the pending tool call"
|
|
286
|
+
end
|
|
287
|
+
{ approved: true, rejected: false }[action.status]
|
|
288
|
+
end
|
|
289
|
+
|
|
258
290
|
# Approval gate, called by the ToolEnvelope at stage 6 when the
|
|
259
291
|
# tool requires approval. Creates/queries the PendingAction (deterministic id
|
|
260
292
|
# by task+turn+tool — per-tool correlation as with the side-effect),
|
|
@@ -281,6 +313,7 @@ module Insika
|
|
|
281
313
|
end
|
|
282
314
|
|
|
283
315
|
@task_store.transition(task.id, to: :waiting) if @task_store.find(task.id).status == :running
|
|
316
|
+
flush_llm_diagnostics(task) # the wait may last hours
|
|
284
317
|
|
|
285
318
|
# Awaits the resolution of THIS pending. A spurious :approval (duplicate or
|
|
286
319
|
# from another pending of the same actor) that wakes up before resolution is
|
|
@@ -349,12 +382,25 @@ module Insika
|
|
|
349
382
|
return nil unless @supervised && session_id
|
|
350
383
|
|
|
351
384
|
policy = queue_policy(profile, session_id)
|
|
352
|
-
|
|
385
|
+
# The MODE is the whole gate. `debounce_ms` used to be required here too,
|
|
386
|
+
# which made "the message arrived before the turn started" mean only "inside a
|
|
387
|
+
# window an operator configured": a burst landing on a turn already queued
|
|
388
|
+
# behind another joined nothing and became a third turn. A window still
|
|
389
|
+
# widens the door (it holds the turn at the front of the FIFO); it no longer
|
|
390
|
+
# is the door.
|
|
391
|
+
return nil unless policy.collect?
|
|
353
392
|
|
|
354
393
|
actor = @session_actors[session_id]
|
|
355
394
|
return nil unless actor&.alive?
|
|
356
395
|
|
|
357
|
-
|
|
396
|
+
# FRAMED, like the steer door does at `SteerInjector#absorb_pending!`. The two
|
|
397
|
+
# doors used to disagree: a steered message became its OWN `user` message in the
|
|
398
|
+
# transcript (so the model saw a separate ask) while a merged one was concatenated
|
|
399
|
+
# into the queued turn's single payload by a bare "\n" — five customer messages
|
|
400
|
+
# arrived as one run-on paragraph, and a trailing constraint got answered by
|
|
401
|
+
# nothing. `steer_join` is the knob that already exists for exactly this, and a
|
|
402
|
+
# nil one returns the text untouched, so a wiring that never set it is unchanged.
|
|
403
|
+
actor.collect(policy.frame(text))
|
|
358
404
|
end
|
|
359
405
|
|
|
360
406
|
# the `steer` door: a message for a session whose turn is ALREADY
|
|
@@ -376,8 +422,8 @@ module Insika
|
|
|
376
422
|
return nil unless session_actor&.alive?
|
|
377
423
|
|
|
378
424
|
# No turn running (or one still at the door): there is nothing to steer INTO.
|
|
379
|
-
# A turn at the door is the collect door's
|
|
380
|
-
#
|
|
425
|
+
# A turn at the door is the collect door's business — a steer agent merges
|
|
426
|
+
# there instead (`collect?` is true for :steer), so no message waits on either.
|
|
381
427
|
task = session_actor.current_task
|
|
382
428
|
return nil if task.nil?
|
|
383
429
|
# A workflow turn orchestrates RubyLLM itself and has no Insika chat to append to
|
|
@@ -503,10 +549,9 @@ module Insika
|
|
|
503
549
|
fail_spawn(task, e)
|
|
504
550
|
end
|
|
505
551
|
|
|
506
|
-
# Shuts down all SessionActors (server shutdown / tests
|
|
507
|
-
# forever on dequeue when idle).
|
|
552
|
+
# Shuts down all SessionActors (server shutdown / tests).
|
|
508
553
|
def stop_session_actors
|
|
509
|
-
@session_actors.
|
|
554
|
+
@session_actors.values.each(&:stop)
|
|
510
555
|
@session_actors.clear
|
|
511
556
|
nil
|
|
512
557
|
end
|
|
@@ -587,76 +632,104 @@ module Insika
|
|
|
587
632
|
|
|
588
633
|
# Stages 2..9. Runs INSIDE the task's fiber.
|
|
589
634
|
def execute(task, profile:, actor:, resume_from: nil, timing: nil)
|
|
635
|
+
state = nil
|
|
636
|
+
@llm_buffers[task.id] = []
|
|
637
|
+
Fiber[TurnTiming::FIBER_KEY] = nil # a failure before run_pipeline has no clock of its own
|
|
590
638
|
# Resume of a crash orphan: the interrupted attempt's Execution was left OPEN
|
|
591
639
|
# (the fiber died). The TaskStore forbids opening a second one while one is
|
|
592
640
|
# open -> close the orphan as :interrupted before opening the N+1 (a new
|
|
593
641
|
# entry, never overwrites).
|
|
594
642
|
close_orphan_execution(task) if resume_from
|
|
595
|
-
|
|
596
|
-
#
|
|
597
|
-
|
|
598
|
-
status = @task_store.find(task.id).status
|
|
599
|
-
@task_store.transition(task.id, to: :running) if %i[queued paused waiting].include?(status)
|
|
643
|
+
# attempt N+1, and queued (normal spawn) or paused/waiting (resume) ->
|
|
644
|
+
# running, in one write. An orphan is already :running and stays so.
|
|
645
|
+
@task_store.start_execution(task.id)
|
|
600
646
|
emit(:task_started, started_data(task, profile), task: task)
|
|
601
647
|
|
|
648
|
+
if profile.respond_to?(:shared_conversations) && profile.shared_conversations
|
|
649
|
+
@shared_turns[task.id] = { committed: false }
|
|
650
|
+
raise StoreError, "shared conversation service is not configured" unless @shared_conversations
|
|
651
|
+
@shared_turns[task.id][:memory] = (profile.memory || profile.knowledge) ? @shared_conversations.memory_context(task:task,profile:profile) : {}
|
|
652
|
+
@shared_turns[task.id][:history] = if resume_from
|
|
653
|
+
@shared_conversations.resume_turn(task: task, profile: profile, checkpoint: resume_from)
|
|
654
|
+
else
|
|
655
|
+
@shared_conversations.begin_turn(task: task, profile: profile)
|
|
656
|
+
end
|
|
657
|
+
end
|
|
602
658
|
actor.drain!
|
|
603
|
-
run_pipeline(task, profile, actor, resume_from, timing)
|
|
659
|
+
run_pipeline(task, profile, actor, resume_from, timing) { |current| state = current }
|
|
604
660
|
# SINGLE capture at the top of the fiber: a single place maps
|
|
605
661
|
# error -> terminal state -> events. Stages do no rescue of their own
|
|
606
662
|
# (except tool, RubyLLM semantics). The fiber NEVER re-raises.
|
|
663
|
+
rescue SharedConversations::Recovered => recovered
|
|
664
|
+
@shared_turns[task.id][:committed] = true
|
|
665
|
+
@task_store.complete_execution(task.id, outcome: :completed)
|
|
666
|
+
emit(:content, {delta: recovered.content}, task: task)
|
|
667
|
+
recovery_state = TurnState.new(task: task, profile: profile, turn: 1, message: '')
|
|
668
|
+
recovery_state.evidence_attachments = recovered.attachments
|
|
669
|
+
finalize_channel_delivery(task, recovered.content, recovery_state, timing)
|
|
670
|
+
data = {task_id: task.id, content: recovered.content}
|
|
671
|
+
data[:output_parts] = recovered.output_parts unless recovered.output_parts.empty?
|
|
672
|
+
emit(:task_completed, data, task: task)
|
|
607
673
|
rescue CancelledError
|
|
608
674
|
# cancel is not an error: transition WITHOUT error: (does not close the
|
|
609
675
|
# Execution), then finish_execution closes it with outcome :cancelled.
|
|
610
676
|
@task_store.transition(task.id, to: :cancelled)
|
|
611
677
|
@task_store.finish_execution(task.id, outcome: :cancelled)
|
|
612
|
-
|
|
678
|
+
data = { task_id: task.id }
|
|
679
|
+
data[:usage] = state.usage if state&.usage
|
|
680
|
+
emit(:task_cancelled, data, task: task)
|
|
613
681
|
rescue PolicyDenied => e
|
|
614
682
|
emit(:policy_denied, { policy: e.policy, reason: e.reason }, task: task)
|
|
615
|
-
fail_task(task, e, stage: :policy)
|
|
683
|
+
fail_task(task, e, stage: :policy, usage: state&.usage, profile: profile)
|
|
616
684
|
rescue BudgetExceeded => e
|
|
617
685
|
# WS2 hard budget: a typed, retryable failure — the envelope reads
|
|
618
686
|
# budget_exceeded + retry_after (window roll), never a silent drop.
|
|
619
|
-
fail_task(task, e, stage: :budget)
|
|
687
|
+
fail_task(task, e, stage: :budget, usage: state&.usage, profile: profile)
|
|
620
688
|
rescue CircuitOpenError => e
|
|
621
689
|
# WS3 breaker: the turn died BEFORE the provider call — the envelope
|
|
622
690
|
# reads circuit_open + retry_after (cooldown remaining). Worth its own
|
|
623
691
|
# stage: an open breaker is a reliability decision, not an error bug.
|
|
624
|
-
fail_task(task, e, stage: :reliability)
|
|
692
|
+
fail_task(task, e, stage: :reliability, usage: state&.usage, profile: profile)
|
|
625
693
|
rescue Insika::RoutingError => e
|
|
626
694
|
# WS4: a route's delegate is missing or its turn failed — an operator
|
|
627
695
|
# config error, staged so the envelope names routing, never :unknown.
|
|
628
|
-
fail_task(task, e, stage: :routing)
|
|
696
|
+
fail_task(task, e, stage: :routing, usage: state&.usage, profile: profile)
|
|
629
697
|
rescue Insika::MediaError => e
|
|
630
698
|
# WS9: a voice message that could not be fetched/transcribed (or a media
|
|
631
699
|
# URL the egress guard refused) — heard-loud, never a silent drop.
|
|
632
|
-
fail_task(task, e, stage: :media)
|
|
700
|
+
fail_task(task, e, stage: :media, usage: state&.usage, profile: profile)
|
|
633
701
|
rescue Insika::WorkflowSchemaError => e
|
|
634
702
|
# a workflow OUTPUT that violates its output_schema. Distinct
|
|
635
703
|
# stage so a contract breach is not conflated with an :unknown failure. (INPUT
|
|
636
704
|
# is validated synchronously in TriggerWorkflow -> 422, never reaches here.)
|
|
637
|
-
fail_task(task, e, stage: :workflow_schema)
|
|
705
|
+
fail_task(task, e, stage: :workflow_schema, usage: state&.usage, profile: profile)
|
|
638
706
|
rescue ContextError => e
|
|
639
|
-
fail_task(task, e, stage: :context)
|
|
707
|
+
fail_task(task, e, stage: :context, usage: state&.usage, profile: profile)
|
|
640
708
|
rescue CapabilityError => e
|
|
641
|
-
fail_task(task, e, stage: :capability)
|
|
709
|
+
fail_task(task, e, stage: :capability, usage: state&.usage, profile: profile)
|
|
642
710
|
rescue ProviderError => e
|
|
643
|
-
fail_task(task, e, stage: :ruby_llm)
|
|
711
|
+
fail_task(task, e, stage: :ruby_llm, usage: state&.usage, profile: profile)
|
|
644
712
|
rescue StoreError => e
|
|
645
|
-
fail_task(task, e, stage: :persistence)
|
|
713
|
+
fail_task(task, e, stage: :persistence, usage: state&.usage, profile: profile)
|
|
646
714
|
rescue TimeoutError => e
|
|
647
|
-
fail_task(task, e, stage: e.stage)
|
|
715
|
+
fail_task(task, e, stage: e.stage, usage: state&.usage, profile: profile)
|
|
648
716
|
rescue StandardError => e
|
|
649
717
|
# A provider/transport failure is NOT an :unknown bug: wrap it with its
|
|
650
718
|
# action classification (B9) so the envelope can quote retryable and the
|
|
651
719
|
# provider's own retry_after (A8). The classifier is class-name based —
|
|
652
720
|
# the :ruby_llm stage stays reachable even under the smoke-shim's fake.
|
|
653
721
|
if ProviderErrorClassifier.provider_error?(e)
|
|
654
|
-
fail_task(task, ProviderErrorClassifier.wrap(e), stage: :ruby_llm)
|
|
722
|
+
fail_task(task, ProviderErrorClassifier.wrap(e), stage: :ruby_llm, usage: state&.usage, profile: profile)
|
|
655
723
|
else
|
|
656
|
-
fail_task(task, e, stage: :unknown)
|
|
724
|
+
fail_task(task, e, stage: :unknown, usage: state&.usage, profile: profile)
|
|
657
725
|
end
|
|
658
726
|
ensure
|
|
727
|
+
@shared_turns.delete(task.id)
|
|
728
|
+
llm_entries = @llm_buffers.delete(task.id)
|
|
659
729
|
@running.delete(task.id) # ALWAYS deregister (a false-positive running? would break the resume)
|
|
730
|
+
# after deregistering: the writes may wait on the store, and a resume or an
|
|
731
|
+
# approve arriving meanwhile must not find a finished turn "running"
|
|
732
|
+
write_llm_diagnostics(task, llm_entries)
|
|
660
733
|
# Deregistered FIRST on purpose: from here on the `steer` door finds no actor for
|
|
661
734
|
# this session and answers nil, so a message arriving during the release becomes
|
|
662
735
|
# its own turn instead of a post into a mailbox nobody reads again.
|
|
@@ -713,7 +786,8 @@ module Insika
|
|
|
713
786
|
return existing if existing&.alive?
|
|
714
787
|
|
|
715
788
|
@session_actors[session_id] =
|
|
716
|
-
SessionActor.new(session_id: session_id, executor: self, parent: turn_parent
|
|
789
|
+
SessionActor.new(session_id: session_id, executor: self, parent: turn_parent,
|
|
790
|
+
on_idle: ->(actor) { @session_actors.delete(session_id) if @session_actors[session_id].equal?(actor) })
|
|
717
791
|
end
|
|
718
792
|
|
|
719
793
|
# a turn that arrived while the process is draining: left
|
|
@@ -771,6 +845,7 @@ module Insika
|
|
|
771
845
|
# the harvest engine lives on the same supervisor — its
|
|
772
846
|
# worker fiber re-scans idle sessions off the turn path.
|
|
773
847
|
@harvest_engine&.start(parent: @supervisor)
|
|
848
|
+
@memory_trim&.start(parent: @supervisor)
|
|
774
849
|
@supervisor
|
|
775
850
|
end
|
|
776
851
|
|
|
@@ -779,6 +854,7 @@ module Insika
|
|
|
779
854
|
# than once in a turn collides (the 2nd reuses the 1st's decision) — per-step
|
|
780
855
|
# checkpointing is a future slice. One call per tool is safe.
|
|
781
856
|
def pending_id(task_id, turn, tool) = "#{task_id}:#{turn}:#{tool}"
|
|
857
|
+
def native_pending_id(task_id, turn, call_id) = "#{task_id}:#{turn}:call:#{call_id}"
|
|
782
858
|
|
|
783
859
|
# all readings of the persisted command go through rebuild_command
|
|
784
860
|
# (the single normalizer). command_type stays a STRING (the :task_started
|
|
@@ -799,7 +875,7 @@ module Insika
|
|
|
799
875
|
# the open Execution (real TaskStore) — do NOT call finish_execution
|
|
800
876
|
# (double-close). The previous checkpoint is NEVER touched on failure. Never
|
|
801
877
|
# re-raises: the fiber dies clean.
|
|
802
|
-
def fail_task(task, error, stage:)
|
|
878
|
+
def fail_task(task, error, stage:, usage: nil, profile: nil)
|
|
803
879
|
# Defense-in-depth: if the task is already terminal (e.g. a failure in
|
|
804
880
|
# cleanup AFTER transition(:completed)), completed->failed is invalid and
|
|
805
881
|
# would raise ArgumentError INSIDE the rescue, leaking from the fiber. In
|
|
@@ -820,7 +896,9 @@ module Insika
|
|
|
820
896
|
spec = { class: error.class.name, message: error.message, stage: stage }
|
|
821
897
|
spec = spec.merge(classification) unless classification.empty?
|
|
822
898
|
@task_store.transition(task.id, to: :failed, error: spec)
|
|
899
|
+
record_failed_turn(task, profile, usage, stage)
|
|
823
900
|
data = { task_id: task.id, error: error.class.name, message: error.message }
|
|
901
|
+
data[:usage] = usage if usage
|
|
824
902
|
data = data.merge(classification) unless classification.empty?
|
|
825
903
|
emit(:task_failed, data, task: task)
|
|
826
904
|
# a FAILED delegation child still delivers — the parent
|
|
@@ -836,10 +914,10 @@ module Insika
|
|
|
836
914
|
# turn-timeout wrapping everything via Async::Task#with_timeout — NEVER
|
|
837
915
|
# stdlib Timeout.timeout.
|
|
838
916
|
def run_pipeline(task, profile, actor, resume_from, timing = nil)
|
|
839
|
-
#
|
|
840
|
-
#
|
|
841
|
-
#
|
|
842
|
-
#
|
|
917
|
+
# Every turn runs the clock (a few clock reads): its windows become the
|
|
918
|
+
# turn row in ModelMetricsStore. INSIKA_TURN_TIMING only widens what the
|
|
919
|
+
# response exposes (`breakdown:`); with it off a channel turn still
|
|
920
|
+
# reports first_balloon_ms (inbound -> first outbox flush).
|
|
843
921
|
#
|
|
844
922
|
# A channel turn may already carry its clock: `SendMessage` stamped
|
|
845
923
|
# `:inbound` at 202 acceptance (before the debounce window and the
|
|
@@ -848,9 +926,11 @@ module Insika
|
|
|
848
926
|
# engine-initiated) falls back to allocating and stamps now — `mark` is
|
|
849
927
|
# first-write-wins, so a threaded clock is never re-stamped.
|
|
850
928
|
channel_turn = !channel_transport(task).nil?
|
|
851
|
-
timing ||=
|
|
852
|
-
|
|
853
|
-
|
|
929
|
+
timing ||= TurnTiming.new(breakdown: TurnTiming.enabled?)
|
|
930
|
+
# Store calls count into this turn's clock (Stores::TurnCounter). Assigned
|
|
931
|
+
# even when nil, so a turn without a clock never feeds the previous one's.
|
|
932
|
+
Fiber[TurnTiming::FIBER_KEY] = timing
|
|
933
|
+
Fiber[ConfigStore::TURN_KEY] = true # config reads from here on may be cached
|
|
854
934
|
timing&.mark(:inbound) if channel_turn
|
|
855
935
|
timing&.mark(:prep_start)
|
|
856
936
|
state = build_turn_state(task, profile, resume_from)
|
|
@@ -863,12 +943,21 @@ module Insika
|
|
|
863
943
|
# possibly rewritten by before_task). Subject == result == TurnState
|
|
864
944
|
# (the Response content lives in the :done event).
|
|
865
945
|
@hooks.around(:task, state) do |state|
|
|
866
|
-
|
|
946
|
+
yield state if block_given?
|
|
947
|
+
prepare = ->(st) { prepare_turn(task, profile, st, actor, resume_from) }
|
|
948
|
+
paid_retrieval = (Coercion.truthy?(profile.knowledge&.dig("retrieve")) && profile.knowledge&.dig("rerank")) ||
|
|
949
|
+
(profile.memory && profile.memory_retrieval)
|
|
950
|
+
middleware_options = if paid_retrieval && @middleware.is_a?(MiddlewareStack)
|
|
951
|
+
{ prepare: prepare }
|
|
952
|
+
else
|
|
953
|
+
prepare.call(state)
|
|
954
|
+
{}
|
|
955
|
+
end
|
|
867
956
|
|
|
868
957
|
# stage 4: Middleware wraps stages 5-9. A link that
|
|
869
958
|
# short-circuits does NOT call the terminal and sets state.halt_reason.
|
|
870
959
|
terminal_ran = false
|
|
871
|
-
@middleware.call(state) do |st|
|
|
960
|
+
@middleware.call(state, **middleware_options) do |st|
|
|
872
961
|
raise Insika::Error, "turn halted: #{st.halt_reason}" if st.halt_reason
|
|
873
962
|
|
|
874
963
|
terminal_ran = true
|
|
@@ -909,10 +998,15 @@ module Insika
|
|
|
909
998
|
turn = resume_from ? resume_from.turn : 1
|
|
910
999
|
state = TurnState.new(task: task, profile: profile, turn: turn,
|
|
911
1000
|
message: extract_message(task))
|
|
1001
|
+
state.caller_context = extract_caller_context(task)
|
|
1002
|
+
state.shared_conversations = @shared_conversations if @shared_turns[task.id]
|
|
912
1003
|
state.tenant = memory_tenant(task) # WRITE-path memory scope (`remember`); =chat
|
|
913
1004
|
stamp_customer_session(task, profile)
|
|
914
1005
|
state.turn_context = build_turn_context(task, profile, state) # data-tools' ctx.*
|
|
915
1006
|
state.resumed = !resume_from.nil? # EdgeLimiter: an admitted turn is never re-counted
|
|
1007
|
+
state.approval_continuation = resume_from&.continuation
|
|
1008
|
+
state.usage = state.approval_continuation&.fetch("usage", nil)&.transform_keys(&:to_sym)
|
|
1009
|
+
state.fence_max_chars = fence_max_chars if Insika::Fence.enabled?(profile)
|
|
916
1010
|
# resolved for the RUN, not per message, so what the turn accepts cannot
|
|
917
1011
|
# change under it. Same cost as the EdgeLimiter's per-turn resolution. Only for a
|
|
918
1012
|
# SESSION turn: steering needs a session to arrive through, and resolving here for a
|
|
@@ -924,6 +1018,10 @@ module Insika
|
|
|
924
1018
|
# validator/enforcer read the union, stage 8 flushes.
|
|
925
1019
|
state.evidence_ledger = Insika::EvidenceLedger.new(store: @session_store,
|
|
926
1020
|
session_id: task.session_id)
|
|
1021
|
+
if (evidence = state.approval_continuation&.fetch("evidence", nil))
|
|
1022
|
+
state.evidence_ledger.record(evidence["ids"])
|
|
1023
|
+
state.evidence_ledger.record_cards(evidence["cards"])
|
|
1024
|
+
end
|
|
927
1025
|
state
|
|
928
1026
|
end
|
|
929
1027
|
|
|
@@ -980,6 +1078,7 @@ module Insika
|
|
|
980
1078
|
# wire the approval gate into the state (the ToolEnvelope reads it at stage 6).
|
|
981
1079
|
state.actor = actor
|
|
982
1080
|
state.approval_coordinator = self
|
|
1081
|
+
state.pending_action_store = @pending_action_store
|
|
983
1082
|
state.requires_approval = resolution.requires_approval
|
|
984
1083
|
state.allowed_tools = wrap_tools(assemble_tool_instances(resolution.allowed_tools, state), state, skip)
|
|
985
1084
|
state.allowed_skills = resolution.allowed_skills
|
|
@@ -1066,7 +1165,7 @@ module Insika
|
|
|
1066
1165
|
# approximating RubyLLM's rendering (honest in the doc: the reason's job is
|
|
1067
1166
|
# the CONTEXT categories; the tool hash is a guard rail).
|
|
1068
1167
|
def serialize_tools(tools)
|
|
1069
|
-
tools.map { |t| "#{t.name} #{t.description} #{t.
|
|
1168
|
+
tools.map { |t| "#{t.name} #{t.description} #{t.parameters_schema.inspect}" }.join(" ")
|
|
1070
1169
|
end
|
|
1071
1170
|
|
|
1072
1171
|
# the stage-8 stamp — the usage (cached_tokens,
|
|
@@ -1146,7 +1245,7 @@ module Insika
|
|
|
1146
1245
|
# Same yardstick as the fragments (TokenEstimator), so the categories are
|
|
1147
1246
|
# comparable. `parameters` is not guaranteed JSON-safe — inspect it.
|
|
1148
1247
|
def estimate_tools_tokens(tools)
|
|
1149
|
-
TokenEstimator.estimate(tools.map { |t| "#{t.name} #{t.description} #{t.
|
|
1248
|
+
TokenEstimator.estimate(tools.map { |t| "#{t.name} #{t.description} #{t.parameters_schema.inspect}" }.join(" "))
|
|
1150
1249
|
rescue StandardError
|
|
1151
1250
|
0
|
|
1152
1251
|
end
|
|
@@ -1200,6 +1299,7 @@ module Insika
|
|
|
1200
1299
|
# but is not honored (last stage); :cancel here still raises.
|
|
1201
1300
|
actor.drain!
|
|
1202
1301
|
persist_turn(task, profile, st, content, timing: timing)
|
|
1302
|
+
expire_customer_confirmations(task)
|
|
1203
1303
|
|
|
1204
1304
|
# the cache-hit stamp — the usage exists only now. The
|
|
1205
1305
|
# stamped entry is durable before anything is delivered (same slot as the
|
|
@@ -1220,11 +1320,12 @@ module Insika
|
|
|
1220
1320
|
# Additive sibling — the answer text stays text on purpose; the channel
|
|
1221
1321
|
# consumes the parts next to it. Absent when nothing was generated.
|
|
1222
1322
|
data[:output_parts] = st.output_parts if st.output_parts && !st.output_parts.empty?
|
|
1223
|
-
|
|
1323
|
+
exposed = timing&.to_h
|
|
1324
|
+
data[:timing] = exposed unless exposed.nil? || exposed.empty? # opt-in TTFB breakdown (INSIKA_TURN_TIMING)
|
|
1224
1325
|
# best-effort persist of the same timing onto the task record —
|
|
1225
1326
|
# the Studio task page reads it from there. A failure here must not re-fail
|
|
1226
1327
|
# the turn (the task is already committed and the event already carries it).
|
|
1227
|
-
persist_turn_timing(task, timing)
|
|
1328
|
+
persist_turn_timing(task, timing, profile, st.usage)
|
|
1228
1329
|
# WS5: the agent declared it cannot proceed (signal_stuck). The turn still
|
|
1229
1330
|
# COMPLETES (its final message was published) — but the consumer must be able
|
|
1230
1331
|
# to act on that, so the contract carries it twice: a dedicated :turn_stuck
|
|
@@ -1304,13 +1405,6 @@ module Insika
|
|
|
1304
1405
|
# release the redactor's retained tail (a value that never completed into a
|
|
1305
1406
|
# match is emitted redacted-if-needed, not lost) before reading anything back.
|
|
1306
1407
|
output.flush
|
|
1307
|
-
# MERGED, not overwritten: a WS4 routing call already banked its tokens in
|
|
1308
|
-
# state.usage before the ask — the classifier's cost must survive the ask
|
|
1309
|
-
# (it feeds the EdgeLimiter's ceiling/budget and the terminal usage).
|
|
1310
|
-
unless halted?(response)
|
|
1311
|
-
state.usage = merge_usage(with_model_source(usage_of(response), state.model_selection),
|
|
1312
|
-
state.usage)
|
|
1313
|
-
end
|
|
1314
1408
|
|
|
1315
1409
|
# BOUNDARY BEFORE THE ANSWER GOES OUT. A cancel that arrived while the provider
|
|
1316
1410
|
# was working used to be observed at stage 8 — AFTER `:content` had already been
|
|
@@ -1322,10 +1416,23 @@ module Insika
|
|
|
1322
1416
|
# holding a completed answer for an operator would strand it unpublished.
|
|
1323
1417
|
state.actor&.drain!
|
|
1324
1418
|
|
|
1325
|
-
output.publish(turn_answer(response, asked, output, filter))
|
|
1419
|
+
output.publish(without_prompt_echo(state, turn_answer(response, asked, output, filter)))
|
|
1326
1420
|
end
|
|
1327
1421
|
end
|
|
1328
1422
|
|
|
1423
|
+
# The last gate before the customer reads the answer: a reply that pasted the
|
|
1424
|
+
# system prompt keeps only what came before it (or the safe refusal when that is
|
|
1425
|
+
# nothing). The cut rides :guardrail_flagged like any other, with action "cut".
|
|
1426
|
+
def without_prompt_echo(state, answer)
|
|
1427
|
+
text, echoed = Insika::Safety::PromptEcho.cut(answer, Insika::ModelVisible.instructions_of(state.chat))
|
|
1428
|
+
return answer if echoed.zero?
|
|
1429
|
+
|
|
1430
|
+
state.guardrail_flags = Array(state.guardrail_flags) +
|
|
1431
|
+
[{ category: "prompt_leak", source: :deterministic, action: "cut",
|
|
1432
|
+
detail: "#{echoed} chars of the system prompt removed" }]
|
|
1433
|
+
text.empty? ? Insika::Safety::SafeResponses.for(:injection) : text
|
|
1434
|
+
end
|
|
1435
|
+
|
|
1329
1436
|
# stage 6, plain path: the single ask on the assembled chat. Fresh TurnOutput
|
|
1330
1437
|
# + steer wiring per interaction (registered on state.chat, which the solve
|
|
1331
1438
|
# already assembled). -> { response:, asked:, output: }.
|
|
@@ -1336,8 +1443,8 @@ module Insika
|
|
|
1336
1443
|
{ response: asked, asked: asked, output: output }
|
|
1337
1444
|
end
|
|
1338
1445
|
|
|
1339
|
-
#
|
|
1340
|
-
#
|
|
1446
|
+
# Native chats own transport retries. Reliability keeps the circuit breaker
|
|
1447
|
+
# and fallback rotation (and retries for non-native integrations). Each rotation gets a fresh chat
|
|
1341
1448
|
# + output (a failed `ask` leaves its message in the chat, so re-asking the
|
|
1342
1449
|
# same one would double the input) and, on a fallback, state.model_selection
|
|
1343
1450
|
# follows — the turn's usage is attributed to the model that actually spoke
|
|
@@ -1349,8 +1456,10 @@ module Insika
|
|
|
1349
1456
|
attempt_output = nil
|
|
1350
1457
|
attempt_asked = nil
|
|
1351
1458
|
primary = state.model_selection
|
|
1459
|
+
# Native transport owns retries per request; the coordinator only rotates models.
|
|
1460
|
+
coordinator_policy = state.chat.is_a?(RubyLLM::Chat) ? policy.merge("retries" => 0) : policy
|
|
1352
1461
|
response = @reliability.call(
|
|
1353
|
-
policy:
|
|
1462
|
+
policy: coordinator_policy, tenant: task_tenant(task), agent: state.profile.id.to_s,
|
|
1354
1463
|
selection: primary, chain: reliability_chain(state)
|
|
1355
1464
|
) do |selection, tries|
|
|
1356
1465
|
first_attempt = state.chat && selection == primary && tries == 1
|
|
@@ -1361,7 +1470,9 @@ module Insika
|
|
|
1361
1470
|
chat = build_attempt_chat(state, selection)
|
|
1362
1471
|
state.chat = chat
|
|
1363
1472
|
end
|
|
1473
|
+
filter&.flush # Discard the failed stream's retained tail before the next attempt.
|
|
1364
1474
|
attempt_output = new_turn_output(task, state, filter)
|
|
1475
|
+
configure_reliable_chat(state.chat, policy)
|
|
1365
1476
|
wire_chat_output(task, state, attempt_output)
|
|
1366
1477
|
attempt_asked = ask_on(task, state, state.chat, attempt_output, timing)
|
|
1367
1478
|
attempt_asked
|
|
@@ -1369,6 +1480,19 @@ module Insika
|
|
|
1369
1480
|
{ response: response, asked: attempt_asked, output: attempt_output }
|
|
1370
1481
|
end
|
|
1371
1482
|
|
|
1483
|
+
def configure_reliable_chat(chat, policy)
|
|
1484
|
+
return unless chat.is_a?(RubyLLM::Chat)
|
|
1485
|
+
|
|
1486
|
+
config = (chat.context&.config || RubyLLM.config).dup
|
|
1487
|
+
config.max_retries = [policy["retries"].to_i, 0].max
|
|
1488
|
+
timeout = policy["timeout"].to_i
|
|
1489
|
+
config.request_timeout = timeout.positive? ? timeout : Reliability::DEFAULT_TIMEOUT
|
|
1490
|
+
if policy["backoff"]
|
|
1491
|
+
config.retry_backoff_factor = policy["backoff"] == "exponential" ? 2 : 1
|
|
1492
|
+
end
|
|
1493
|
+
chat.with_context(RubyLLM::Context.new(config))
|
|
1494
|
+
end
|
|
1495
|
+
|
|
1372
1496
|
# The fallback chain for WS3: profile's `reliability["fallback"]` refs first,
|
|
1373
1497
|
# then the platform-resolved fallbacks (ModelSelection). Each node is a
|
|
1374
1498
|
# ModelSelection (the SAME duck the primary is — usage attribution and
|
|
@@ -1376,6 +1500,8 @@ module Insika
|
|
|
1376
1500
|
# primary. Deduped by ref, primary excluded.
|
|
1377
1501
|
def reliability_chain(state)
|
|
1378
1502
|
primary = state.model_selection
|
|
1503
|
+
return [] if primary.pinned?
|
|
1504
|
+
|
|
1379
1505
|
refs = Array((state.profile.reliability || {})["fallback"]).map(&:to_s)
|
|
1380
1506
|
nodes = refs.filter_map { |r| parse_model_ref(r) }.reject { |n| n[:model].to_s.empty? }
|
|
1381
1507
|
nodes.concat(Array(primary.fallbacks).map { |f| { model: f[:model], provider: f[:provider] } })
|
|
@@ -1392,6 +1518,19 @@ module Insika
|
|
|
1392
1518
|
: seen.keys.any? { |known| known.split("/").last == ref }
|
|
1393
1519
|
next if duplicate
|
|
1394
1520
|
|
|
1521
|
+
unless ModelPolicy.allowed?(state.profile.model_policy, model: node[:model], provider: node[:provider])
|
|
1522
|
+
next if node[:provider]
|
|
1523
|
+
next unless Array(ModelPolicy.allow_list(state.profile.model_policy)).any? { |allowed| allowed.to_s.include?("/") }
|
|
1524
|
+
|
|
1525
|
+
begin
|
|
1526
|
+
node[:provider] = RubyLLM.models.find(node[:model]).provider.to_sym
|
|
1527
|
+
rescue RubyLLM::ModelNotFoundError
|
|
1528
|
+
next
|
|
1529
|
+
end
|
|
1530
|
+
next unless ModelPolicy.allowed?(state.profile.model_policy, model: node[:model], provider: node[:provider])
|
|
1531
|
+
ref = model_ref(node)
|
|
1532
|
+
end
|
|
1533
|
+
|
|
1395
1534
|
seen[ref] = true
|
|
1396
1535
|
Insika::ModelSelection.new(model: node[:model], provider: node[:provider],
|
|
1397
1536
|
source: :fallback, params: primary.params, fallbacks: [])
|
|
@@ -1410,6 +1549,11 @@ module Insika
|
|
|
1410
1549
|
# for data/HTTP tools. The engine transports media, never meaning: no
|
|
1411
1550
|
# speech/vision logic beyond the call itself.
|
|
1412
1551
|
def run_media_stage(task, state)
|
|
1552
|
+
if state.shared_conversations
|
|
1553
|
+
attachments = state.shared_conversations.input_attachments(task: task)
|
|
1554
|
+
state.media_attachments = attachments unless attachments.empty?
|
|
1555
|
+
return
|
|
1556
|
+
end
|
|
1413
1557
|
# a consumer that pre-transcribed voice text labels it `source: voice`;
|
|
1414
1558
|
# the marker rides the turn even when there are no audio PARTS left.
|
|
1415
1559
|
state.message_source = :voice if rebuild_command(task).payload["source"].to_s == "voice"
|
|
@@ -1467,7 +1611,7 @@ module Insika
|
|
|
1467
1611
|
transcriber = @media || Insika::Media.default_transcriber(
|
|
1468
1612
|
stt_model: Insika::EnvSchema.read("INSIKA_STT_MODEL"),
|
|
1469
1613
|
stt_language: Insika::EnvSchema.read("INSIKA_STT_LANGUAGE"),
|
|
1470
|
-
stt_prompt: resolved_stt_prompt(state.profile)
|
|
1614
|
+
stt_prompt: resolved_stt_prompt(state.profile), context: @llm
|
|
1471
1615
|
)
|
|
1472
1616
|
transcriber.call(url)
|
|
1473
1617
|
end
|
|
@@ -1551,8 +1695,9 @@ module Insika
|
|
|
1551
1695
|
# "routing", same turn number as the answer ask).
|
|
1552
1696
|
def route_ask(selection, prompt, message, task, st)
|
|
1553
1697
|
require "ruby_llm"
|
|
1554
|
-
chat = (
|
|
1555
|
-
|
|
1698
|
+
chat = llm_operation_context(st, selection.model, operation: "routing")
|
|
1699
|
+
.chat(model: selection.model, provider: selection.provider,
|
|
1700
|
+
assume_model_exists: selection.assume_model_exists?)
|
|
1556
1701
|
chat.with_instructions(prompt) if chat.respond_to?(:with_instructions)
|
|
1557
1702
|
response = chat.ask(message.to_s)
|
|
1558
1703
|
record_model_visible(task, st, chat, part: "routing")
|
|
@@ -1603,7 +1748,7 @@ module Insika
|
|
|
1603
1748
|
"routed delegate '#{agent_id}' at depth #{depth} exceeds cap #{cap}"
|
|
1604
1749
|
end
|
|
1605
1750
|
|
|
1606
|
-
result = spawn_and_await_child(child, state
|
|
1751
|
+
result = spawn_and_await_child(child, ask_text(state), depth, state)
|
|
1607
1752
|
if result[:error]
|
|
1608
1753
|
raise Insika::RoutingError, "routed delegate '#{agent_id}' failed: #{result[:error]}"
|
|
1609
1754
|
end
|
|
@@ -1617,12 +1762,18 @@ module Insika
|
|
|
1617
1762
|
def merge_usage(main, extra)
|
|
1618
1763
|
return main || extra if main.nil? || extra.nil?
|
|
1619
1764
|
|
|
1620
|
-
Insika::Routing::TOKEN_FIELDS.each_with_object(main
|
|
1765
|
+
merged = Insika::Routing::TOKEN_FIELDS.each_with_object(extra.merge(main)) do |k, acc|
|
|
1621
1766
|
next if extra[k].nil?
|
|
1622
1767
|
next unless main.key?(k) || extra[k].to_i.positive?
|
|
1623
1768
|
|
|
1624
1769
|
acc[k] = main[k].to_i + extra[k].to_i
|
|
1625
1770
|
end
|
|
1771
|
+
if main.key?(:cost_usd) || extra.key?(:cost_usd)
|
|
1772
|
+
costs = [main[:cost_usd], extra[:cost_usd]]
|
|
1773
|
+
merged[:cost_usd] = costs.sum unless costs.any?(&:nil?)
|
|
1774
|
+
merged[:cost_usd] = nil if costs.any?(&:nil?)
|
|
1775
|
+
end
|
|
1776
|
+
merged
|
|
1626
1777
|
end
|
|
1627
1778
|
|
|
1628
1779
|
# "provider/model" for any selection duck (ModelSelection | { model:, provider: }).
|
|
@@ -1654,7 +1805,7 @@ module Insika
|
|
|
1654
1805
|
# state (the seed history is identical), baseline reset -> the transcript
|
|
1655
1806
|
# recorded from than point is the attempt that spoke.
|
|
1656
1807
|
def build_attempt_chat(state, selection)
|
|
1657
|
-
chat = build_chat(selection, state.model_selection)
|
|
1808
|
+
chat = build_chat(selection, state.model_selection, state: state)
|
|
1658
1809
|
@chat_builder.assemble(chat, state, emit: ->(type, data) { emit(type, data, task: state.task) })
|
|
1659
1810
|
state.chat_baseline = Array(chat.messages).size if chat.respond_to?(:messages)
|
|
1660
1811
|
chat
|
|
@@ -1662,6 +1813,7 @@ module Insika
|
|
|
1662
1813
|
|
|
1663
1814
|
def new_turn_output(task, state, filter)
|
|
1664
1815
|
TurnOutput.new(filter: filter, emit: ->(type, data) { emit(type, data, task: task) },
|
|
1816
|
+
halt_text: state.approval_continuation&.fetch("halt_text", ""),
|
|
1665
1817
|
public_intermediate: state.profile.stream_public?(:intermediate))
|
|
1666
1818
|
end
|
|
1667
1819
|
|
|
@@ -1670,6 +1822,7 @@ module Insika
|
|
|
1670
1822
|
# anything is appended after it — the gem's callbacks are additive and run
|
|
1671
1823
|
# in registration order.
|
|
1672
1824
|
def wire_chat_output(task, state, output)
|
|
1825
|
+
state.turn_output = output
|
|
1673
1826
|
chat = state.chat
|
|
1674
1827
|
chat.after_message { |message| output.message_ended(message) } if chat.respond_to?(:after_message)
|
|
1675
1828
|
state.steer_injector = install_steer_injector(task, state)
|
|
@@ -1693,16 +1846,10 @@ module Insika
|
|
|
1693
1846
|
return if halted?(result[:response])
|
|
1694
1847
|
return if injector.absorb_pending!.zero?
|
|
1695
1848
|
|
|
1696
|
-
# TWO provider round trips now. Bank the first one's tokens BEFORE the response
|
|
1697
|
-
# is replaced — stage 6's merge only sees whatever `result[:response]` ends up
|
|
1698
|
-
# being, so without this the extra round silently erases the first round's cost
|
|
1699
|
-
# from the terminal usage and from the EdgeLimiter's budget.
|
|
1700
|
-
state.usage = merge_usage(with_model_source(usage_of(result[:response]), state.model_selection),
|
|
1701
|
-
state.usage)
|
|
1702
1849
|
# COMPLETE, not `ask(nil)`: the messages are already in the history, and an
|
|
1703
1850
|
# ask with no text appends an EMPTY user message after them — which is both a
|
|
1704
1851
|
# duplicate turn-opener and something providers refuse outright.
|
|
1705
|
-
extra = state.chat
|
|
1852
|
+
extra = complete_chat(state, state.chat, &turn_chunk_handler(task, state, result[:output], timing))
|
|
1706
1853
|
result[:response] = extra
|
|
1707
1854
|
result[:asked] = extra
|
|
1708
1855
|
end
|
|
@@ -1720,12 +1867,150 @@ module Insika
|
|
|
1720
1867
|
# An image with no caption asks with NIL, not "": an empty text part is a
|
|
1721
1868
|
# thing some providers refuse, and nil is how RubyLLM says "attachments
|
|
1722
1869
|
# only".
|
|
1723
|
-
if state.
|
|
1724
|
-
|
|
1725
|
-
|
|
1870
|
+
if state.native_approvals && state.approval_continuation
|
|
1871
|
+
continuation = state.approval_continuation
|
|
1872
|
+
@chat_builder.seed_history(chat, continuation.fetch("messages"))
|
|
1873
|
+
if continuation.key?("halt")
|
|
1874
|
+
close_halted_tool_calls(state, chat)
|
|
1875
|
+
return ToolDefinition::Halt.new(content: continuation["halt"])
|
|
1876
|
+
end
|
|
1877
|
+
elsif state.media_attachments
|
|
1878
|
+
text = ask_text(state)
|
|
1879
|
+
chat.ask_later(text.empty? ? nil : text, with: state.media_attachments)
|
|
1726
1880
|
else
|
|
1727
|
-
chat.
|
|
1881
|
+
chat.ask_later(ask_text(state))
|
|
1728
1882
|
end
|
|
1883
|
+
complete_chat(state, chat, &each_chunk)
|
|
1884
|
+
end
|
|
1885
|
+
|
|
1886
|
+
def complete_chat(state, chat, &on_chunk)
|
|
1887
|
+
before = usage_of(chat)
|
|
1888
|
+
state.tool_halt = nil
|
|
1889
|
+
response = nil
|
|
1890
|
+
loop do
|
|
1891
|
+
if chat.awaiting_approval?
|
|
1892
|
+
break unless state.native_approvals
|
|
1893
|
+
|
|
1894
|
+
save_approval_continuation(state, chat, before)
|
|
1895
|
+
await_native_approvals(state, chat)
|
|
1896
|
+
end
|
|
1897
|
+
response = chat.step(&on_chunk)
|
|
1898
|
+
recover_text_tool_call(state, chat, response)
|
|
1899
|
+
close_halted_tool_calls(state, chat) if state.native_approvals && state.tool_halt
|
|
1900
|
+
save_approval_continuation(state, chat, before) if state.tool_halt || !chat.complete?
|
|
1901
|
+
break if state.tool_halt || chat.complete?
|
|
1902
|
+
end
|
|
1903
|
+
state.tool_halt || response
|
|
1904
|
+
ensure
|
|
1905
|
+
# Native chat totals include tool rounds and failed provider attempts. Take
|
|
1906
|
+
# a delta because a steered message can continue this same chat once more.
|
|
1907
|
+
usage = chat_usage_since(chat, before, response)
|
|
1908
|
+
state.usage = merge_usage(with_model_source(usage, state.model_selection), state.usage) if usage
|
|
1909
|
+
end
|
|
1910
|
+
|
|
1911
|
+
# The model wrote the call as TEXT (see TextToolCall). Swap that message for the
|
|
1912
|
+
# tool call it meant, BEFORE the loop checks `complete?`: the chat then has a
|
|
1913
|
+
# pending call, the next step runs the tool, and the model answers from the
|
|
1914
|
+
# result. The swapped message is what the session persists, so the markup never
|
|
1915
|
+
# reaches the history. TurnOutput already took the markup as the answer
|
|
1916
|
+
# candidate at the boundary; the message that ends the turn now replaces it.
|
|
1917
|
+
def recover_text_tool_call(state, chat, response)
|
|
1918
|
+
# RubyLLM is required lazily (the load-guard); FakeChat answers plain structs.
|
|
1919
|
+
return unless defined?(RubyLLM::Message) && response.is_a?(RubyLLM::Message)
|
|
1920
|
+
return unless response.role == :assistant && !response.tool_call?
|
|
1921
|
+
return unless chat.messages.last.equal?(response)
|
|
1922
|
+
|
|
1923
|
+
parsed = TextToolCall.parse(response.content, chat.tools.keys.map(&:to_s)) or return
|
|
1924
|
+
calls = parsed[:calls].to_h do |call|
|
|
1925
|
+
id = "call_text_#{SecureRandom.hex(6)}"
|
|
1926
|
+
[id, RubyLLM::ToolCall.new(id: id, name: call[:name], arguments: call[:arguments])]
|
|
1927
|
+
end
|
|
1928
|
+
chat.messages.pop
|
|
1929
|
+
chat.add_message(RubyLLM::Message.new(
|
|
1930
|
+
role: :assistant, content: parsed[:text], tool_calls: calls, model: response.model,
|
|
1931
|
+
tokens: response.tokens, thinking: response.thinking, raw_reasoning: response.raw_reasoning
|
|
1932
|
+
))
|
|
1933
|
+
names = calls.values.map(&:name)
|
|
1934
|
+
# One line per recovery in the deploy log: it is how the rate of text tool
|
|
1935
|
+
# calls is measured per agent.
|
|
1936
|
+
warn "[insika] text tool call recovered agent=#{state.profile&.id} tools=#{names.join(',')}"
|
|
1937
|
+
emit(:tool_call_recovered, { tools: names }, task: state.task) if state.task
|
|
1938
|
+
end
|
|
1939
|
+
|
|
1940
|
+
def chat_usage_since(chat, before, response = nil)
|
|
1941
|
+
usage = chat.respond_to?(:tokens) ? usage_of(chat) : usage_of(response)
|
|
1942
|
+
if usage
|
|
1943
|
+
if before
|
|
1944
|
+
Insika::Routing::TOKEN_FIELDS.each do |key|
|
|
1945
|
+
usage[key] -= before[key].to_i if usage.key?(key)
|
|
1946
|
+
end
|
|
1947
|
+
if usage[:cost_usd]
|
|
1948
|
+
prior_reported = Insika::Routing::TOKEN_FIELDS.any? { |key| before.key?(key) }
|
|
1949
|
+
usage[:cost_usd] = if before[:cost_usd] || !prior_reported
|
|
1950
|
+
usage[:cost_usd] - before[:cost_usd].to_f
|
|
1951
|
+
end
|
|
1952
|
+
end
|
|
1953
|
+
end
|
|
1954
|
+
end
|
|
1955
|
+
usage
|
|
1956
|
+
end
|
|
1957
|
+
|
|
1958
|
+
# A terminal result can leave approval-gated siblings unanswered. Close them
|
|
1959
|
+
# without executing them so the persisted batch is valid on the next turn.
|
|
1960
|
+
def close_halted_tool_calls(state, chat)
|
|
1961
|
+
index = chat.messages.rindex { |message| message.role == :assistant && message.tool_call? }
|
|
1962
|
+
return unless index
|
|
1963
|
+
|
|
1964
|
+
answered = chat.messages.drop(index + 1).filter_map(&:tool_call_id)
|
|
1965
|
+
chat.messages[index].tool_calls.each_value do |call|
|
|
1966
|
+
action = @pending_action_store&.find(native_pending_id(state.task.id, state.turn, call.id))
|
|
1967
|
+
if action&.status == :pending
|
|
1968
|
+
@pending_action_store.resolve(action.id, decision: :rejected, operator: "engine:halted")
|
|
1969
|
+
emit(:approval_resolved, { pending_id: action.id, decision: :rejected,
|
|
1970
|
+
task_id: state.task.id, resolved_by: "engine:halted" }, task: state.task)
|
|
1971
|
+
end
|
|
1972
|
+
next if answered.include?(call.id)
|
|
1973
|
+
|
|
1974
|
+
chat.add_message(role: :tool, tool_call_id: call.id,
|
|
1975
|
+
content: JSON.generate(error: "Tool call not executed because the turn halted."))
|
|
1976
|
+
end
|
|
1977
|
+
end
|
|
1978
|
+
|
|
1979
|
+
def save_approval_continuation(state, chat, before)
|
|
1980
|
+
return unless state.native_approvals
|
|
1981
|
+
return unless state.approval_continuation || state.profile.reliability || chat.tools.values.any?(&:requires_approval?)
|
|
1982
|
+
|
|
1983
|
+
continuation = {
|
|
1984
|
+
"messages" => recorded_turn_messages(state, chat: chat),
|
|
1985
|
+
"tools" => chat.tools.keys.map(&:to_s),
|
|
1986
|
+
"halt_text" => state.turn_output&.halt_text,
|
|
1987
|
+
"usage" => merge_usage(with_model_source(chat_usage_since(chat, before), state.model_selection), state.usage),
|
|
1988
|
+
"evidence" => { "ids" => state.evidence_ledger&.ids, "cards" => state.evidence_ledger&.cards }
|
|
1989
|
+
}
|
|
1990
|
+
continuation["halt"] = state.tool_halt.content if state.tool_halt
|
|
1991
|
+
saved = @checkpoint_store.save_continuation(state.task.id, turn: state.turn, continuation: continuation)
|
|
1992
|
+
state.approval_continuation = saved.continuation
|
|
1993
|
+
end
|
|
1994
|
+
|
|
1995
|
+
def await_native_approvals(state, chat)
|
|
1996
|
+
task = state.task
|
|
1997
|
+
chat.pending_approvals.each do |call|
|
|
1998
|
+
tool = chat.tools[call.name.to_sym]
|
|
1999
|
+
unless tool.respond_to?(:approval_tool_name)
|
|
2000
|
+
raise Insika::Error, "native approval has no Insika tool binding"
|
|
2001
|
+
end
|
|
2002
|
+
name = tool.approval_tool_name
|
|
2003
|
+
approval_decision(task: task, turn: state.turn, tool: name, args: call.arguments, call_id: call.id)
|
|
2004
|
+
id = native_pending_id(task.id, state.turn, call.id)
|
|
2005
|
+
next if @pending_action_store.find(id)
|
|
2006
|
+
|
|
2007
|
+
@pending_action_store.create(id: id, task_id: task.id, session_id: task.session_id,
|
|
2008
|
+
turn: state.turn, tool: name, args: call.arguments)
|
|
2009
|
+
emit(:approval_requested, { pending_id: id, tool: name, args: call.arguments }, task: task)
|
|
2010
|
+
end
|
|
2011
|
+
@task_store.transition(task.id, to: :waiting) if @task_store.find(task.id).status == :running
|
|
2012
|
+
state.actor.await(reason: :approval) while chat.awaiting_approval?
|
|
2013
|
+
@task_store.transition(task.id, to: :running)
|
|
1729
2014
|
end
|
|
1730
2015
|
|
|
1731
2016
|
# The per-round chunk sink. Built FRESH per round: `ttft_sent` is that round's
|
|
@@ -1787,7 +2072,7 @@ module Insika
|
|
|
1787
2072
|
# stage, because the `:agent` after-hook runs after the message boundary and is
|
|
1788
2073
|
# allowed to have the last word.
|
|
1789
2074
|
def turn_answer(response, asked, output, filter)
|
|
1790
|
-
# HALTED BY A TOOL RESULT (`halt_when`):
|
|
2075
|
+
# HALTED BY A TOOL RESULT (`halt_when`): complete_chat returns the marker
|
|
1791
2076
|
# instead of a Message, and its `content` is the tool PAYLOAD — publishing it
|
|
1792
2077
|
# would ship the envelope to the customer as the answer. The turn is worth
|
|
1793
2078
|
# exactly the lead-in of the message that called the tool (the model's "vou te
|
|
@@ -1820,7 +2105,7 @@ module Insika
|
|
|
1820
2105
|
Insika::ToolDefinition.halt_say_of(content_of(response)).to_s
|
|
1821
2106
|
end
|
|
1822
2107
|
|
|
1823
|
-
def halted?(response) = response.is_a?(
|
|
2108
|
+
def halted?(response) = response.is_a?(Insika::ToolDefinition::Halt)
|
|
1824
2109
|
|
|
1825
2110
|
def content_of(response) = response.respond_to?(:content) ? response.content : response.to_s
|
|
1826
2111
|
|
|
@@ -1865,31 +2150,33 @@ module Insika
|
|
|
1865
2150
|
return usage if usage.nil? || selection.nil?
|
|
1866
2151
|
|
|
1867
2152
|
usage[:model_source] = selection.source
|
|
1868
|
-
usage[:model]
|
|
2153
|
+
unless usage[:model] # the provider omitted it: the resolved id stands in, and prices it
|
|
2154
|
+
usage[:model] = selection.model
|
|
2155
|
+
usage[:cost_usd] ||= @pricing&.cost(usage)
|
|
2156
|
+
end
|
|
2157
|
+
provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
|
|
2158
|
+
usage[:provider] ||= provider.to_s if provider
|
|
1869
2159
|
usage
|
|
1870
2160
|
end
|
|
1871
2161
|
|
|
1872
|
-
#
|
|
1873
|
-
#
|
|
1874
|
-
#
|
|
1875
|
-
# OpenAI Responses usage (input/output/total) + model, consumed by
|
|
1876
|
-
# /v1/responses and Telemetry.
|
|
2162
|
+
# Internal buckets are disjoint; total_tokens is the input/output subtotal.
|
|
2163
|
+
# The Responses adapter projects cache-inclusive input/total at the HTTP edge.
|
|
2164
|
+
# Budgets and evals already add the separate cache buckets to this subtotal.
|
|
1877
2165
|
def usage_of(response)
|
|
1878
|
-
return nil unless response.respond_to?(:
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
# R3: prompt-cache WRITE tokens (Anthropic cache_creation_input_tokens),
|
|
1887
|
-
# billed at ~1.25x. Reported so the first (write) turn vs later (read) turns
|
|
1888
|
-
# are distinguishable in telemetry/usage.
|
|
1889
|
-
if response.respond_to?(:cache_creation_tokens) && response.cache_creation_tokens
|
|
1890
|
-
usage[:cache_creation_tokens] = response.cache_creation_tokens.to_i
|
|
2166
|
+
return nil unless response.respond_to?(:tokens) && (tokens = response.tokens)
|
|
2167
|
+
|
|
2168
|
+
usage = { input_tokens: tokens.input, output_tokens: tokens.output,
|
|
2169
|
+
cached_tokens: tokens.cache_read, cache_creation_tokens: tokens.cache_write }.compact
|
|
2170
|
+
reported = [tokens.input, tokens.output].compact
|
|
2171
|
+
usage[:total_tokens] = reported.sum unless reported.empty?
|
|
2172
|
+
if response.respond_to?(:model) && (model = response.model)
|
|
2173
|
+
usage[:model] = model.respond_to?(:id) ? model.id : model.to_s
|
|
1891
2174
|
end
|
|
1892
|
-
|
|
2175
|
+
# Priced per response under ITS model (RubyLLM's cost; the operator table
|
|
2176
|
+
# only when RubyLLM has none), so a turn that summed a failed primary, a
|
|
2177
|
+
# fallback and the router costs each at its own rate.
|
|
2178
|
+
usage[:cost_usd] = response.cost.total if response.respond_to?(:cost)
|
|
2179
|
+
usage[:cost_usd] = @pricing.cost(usage) if usage[:cost_usd].nil? && @pricing
|
|
1893
2180
|
usage
|
|
1894
2181
|
end
|
|
1895
2182
|
|
|
@@ -1905,6 +2192,17 @@ module Insika
|
|
|
1905
2192
|
payload["message"] || payload["input"]
|
|
1906
2193
|
end
|
|
1907
2194
|
|
|
2195
|
+
def extract_caller_context(task)
|
|
2196
|
+
text = rebuild_command(task).payload["context"].to_s.strip
|
|
2197
|
+
text.empty? ? nil : text
|
|
2198
|
+
end
|
|
2199
|
+
|
|
2200
|
+
# What the model is asked and the transcript keeps: the caller's context, then
|
|
2201
|
+
# the message. Guardrails scanned `message` alone, before this point.
|
|
2202
|
+
def ask_text(state)
|
|
2203
|
+
[state.caller_context, state.message.to_s].compact.reject(&:empty?).join("\n\n")
|
|
2204
|
+
end
|
|
2205
|
+
|
|
1908
2206
|
def command_history(task)
|
|
1909
2207
|
rebuild_command(task).payload["history"]
|
|
1910
2208
|
end
|
|
@@ -1912,19 +2210,27 @@ module Insika
|
|
|
1912
2210
|
def build_context_request(task, profile, state, resume_from)
|
|
1913
2211
|
session = task.session_id ? @session_store.find(task.session_id) : nil
|
|
1914
2212
|
state.session = session # create_chat reads it for the per-chat model pin
|
|
1915
|
-
hist = command_history(task)
|
|
2213
|
+
hist = @shared_turns.dig(task.id, :history) || command_history(task)
|
|
1916
2214
|
# `vars` reconciles the seam (the Request/Session provider already
|
|
1917
2215
|
# called request.vars): session metadata + the explicit `history` in the
|
|
1918
2216
|
# convention the Session provider consumes (vars["history"]).
|
|
1919
2217
|
vars = (session&.vars || {}).dup
|
|
1920
2218
|
vars["history"] = hist if hist
|
|
2219
|
+
vars["shared_memory"] = @shared_turns[task.id][:memory] if @shared_turns[task.id]
|
|
1921
2220
|
# The single type is Insika::ContextRequest (Data); the explicit `history`
|
|
1922
2221
|
# travels in vars["history"] (Session provider convention), not in a field
|
|
1923
2222
|
# of its own. `memory_scope` is the WS8 customer cell (nil = the providers
|
|
1924
2223
|
# fall back to tenant || session, today's behavior).
|
|
2224
|
+
turn = state.turn
|
|
1925
2225
|
ContextRequest.new(profile: profile, message: state.message, session: session,
|
|
1926
2226
|
checkpoint: resume_from, tenant: command_tenant(task), vars: vars,
|
|
1927
|
-
memory_scope: memory_tenant(task)
|
|
2227
|
+
memory_scope: memory_tenant(task),
|
|
2228
|
+
diagnostics: lambda { |type, data|
|
|
2229
|
+
if type == :llm_usage && data["operation"] == "rerank"
|
|
2230
|
+
(state.rerank_usage ||= []) << data
|
|
2231
|
+
end
|
|
2232
|
+
emit(type, data.merge("turn" => turn), task: task)
|
|
2233
|
+
})
|
|
1928
2234
|
end
|
|
1929
2235
|
|
|
1930
2236
|
# task_started payload. Carries the EXPLICIT command tenant so
|
|
@@ -1969,19 +2275,8 @@ module Insika
|
|
|
1969
2275
|
# indistinguishable from a single-tenant customer ref, and the Studio drill
|
|
1970
2276
|
# must not list conversations as customers with a Forget button.
|
|
1971
2277
|
def memory_tenant(task)
|
|
1972
|
-
customer
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
[command_tenant(task), customer].compact.join(":")
|
|
1976
|
-
end
|
|
1977
|
-
|
|
1978
|
-
# The marked per-session scope : "chat:<session id>" -> cell
|
|
1979
|
-
# "memory:chat:<session id>". nil for a one-shot turn (no session) — the
|
|
1980
|
-
# MemoryStore applies _default.
|
|
1981
|
-
def session_scope(session_id)
|
|
1982
|
-
return nil if session_id.nil?
|
|
1983
|
-
|
|
1984
|
-
"#{MemoryStore::SESSION_TAG}:#{session_id}"
|
|
2278
|
+
MemoryStore.scope_for(tenant: command_tenant(task), customer: command_customer(task),
|
|
2279
|
+
session_id: task.session_id)
|
|
1985
2280
|
end
|
|
1986
2281
|
|
|
1987
2282
|
# WS8 + : stamp the customer (WS8 — the `forget_customer`
|
|
@@ -1989,16 +2284,22 @@ module Insika
|
|
|
1989
2284
|
# distillation engine resolves each session's pack through it) on the
|
|
1990
2285
|
# session ONCE (idempotent). A session that does not exist yet (no
|
|
1991
2286
|
# session_id on the turn) is skipped; a look-up failure never breaks the
|
|
1992
|
-
# turn.
|
|
2287
|
+
# turn. The optional labels (SessionStore::CUSTOMER_LABELS: name, phone)
|
|
2288
|
+
# are NOT stamped once: they follow the latest value the caller sends.
|
|
1993
2289
|
def stamp_customer_session(task, profile)
|
|
1994
2290
|
customer = command_customer(task)
|
|
1995
|
-
|
|
2291
|
+
labels = command_customer_labels(task)
|
|
2292
|
+
return if (customer.nil? && labels.empty?) || task.session_id.nil?
|
|
1996
2293
|
|
|
1997
2294
|
session = @session_store&.find(task.session_id)
|
|
1998
|
-
return if session.nil?
|
|
2295
|
+
return if session.nil?
|
|
1999
2296
|
|
|
2000
|
-
|
|
2001
|
-
|
|
2297
|
+
vars = {}
|
|
2298
|
+
if customer && Coercion.presence(session.vars["customer"]).nil?
|
|
2299
|
+
vars.merge!("customer" => customer, "agent" => profile.id)
|
|
2300
|
+
end
|
|
2301
|
+
vars.merge!(labels.reject { |k, v| session.vars[k] == v })
|
|
2302
|
+
@session_store.update_vars(task.session_id, vars) unless vars.empty?
|
|
2002
2303
|
rescue Insika::NotFoundError, ArgumentError
|
|
2003
2304
|
nil
|
|
2004
2305
|
end
|
|
@@ -2011,17 +2312,31 @@ module Insika
|
|
|
2011
2312
|
Coercion.presence(rebuild_command(task).payload["customer"])
|
|
2012
2313
|
end
|
|
2013
2314
|
|
|
2315
|
+
# The optional labels on the command payload (customer_name, customer_phone):
|
|
2316
|
+
# how the Studio shows and finds the conversation. Only a non-blank String
|
|
2317
|
+
# counts; anything else is ignored.
|
|
2318
|
+
def command_customer_labels(task)
|
|
2319
|
+
payload = rebuild_command(task).payload
|
|
2320
|
+
SessionStore::CUSTOMER_LABELS.filter_map do |key|
|
|
2321
|
+
value = payload[key]
|
|
2322
|
+
value.is_a?(String) && (value = Coercion.presence(value)) ? [key, value] : nil
|
|
2323
|
+
end.to_h
|
|
2324
|
+
end
|
|
2325
|
+
|
|
2014
2326
|
# Turn context: the ids the data-tools resolve via
|
|
2015
2327
|
# {{ctx.*}} to emit X-Chat-Id/X-Store-Id/X-Agent-Id to /api/internal/*. They
|
|
2016
|
-
# come from the TURN, never from the model args (R2). chat_id
|
|
2017
|
-
#
|
|
2328
|
+
# come from the TURN, never from the model args (R2). chat_id is the external
|
|
2329
|
+
# session id, without the server-added tenant namespace;
|
|
2018
2330
|
# tenant = the Command tenant (memory) OR chat_id (drop-in default); agent_id =
|
|
2019
2331
|
# profile; store_id = the profile metadata (stable per store, from the pack).
|
|
2020
2332
|
# Absent fields -> nil (the data-tool emits an empty header; in the pilot the
|
|
2021
2333
|
# profile carries store_id). Generic: nothing here mentions a consumer.
|
|
2022
2334
|
def build_turn_context(task, profile, state)
|
|
2335
|
+
tenant = command_tenant(task)
|
|
2023
2336
|
{
|
|
2024
|
-
chat_id: task.session_id,
|
|
2337
|
+
chat_id: tenant ? task.session_id&.delete_prefix("#{tenant}:") : task.session_id,
|
|
2338
|
+
# the conversation itself — MCP clients are kept per conversation.
|
|
2339
|
+
session_id: task.session_id,
|
|
2025
2340
|
agent_id: profile.id,
|
|
2026
2341
|
# the DATA-TOOL header tenant stays the merchant (or the chat), even when
|
|
2027
2342
|
# the memory scope carries a customer — the backend identifies the store,
|
|
@@ -2336,6 +2651,7 @@ module Insika
|
|
|
2336
2651
|
|
|
2337
2652
|
@task_store.transition(task.id, to: :paused)
|
|
2338
2653
|
emit(:task_paused, { task_id: task.id }, task: task)
|
|
2654
|
+
flush_llm_diagnostics(task) # the wait may last hours
|
|
2339
2655
|
actor.await(reason: :paused) # blocks until :resume (or raises on :cancel/:timeout)
|
|
2340
2656
|
@task_store.transition(task.id, to: :running)
|
|
2341
2657
|
emit(:task_resumed, { task_id: task.id }, task: task)
|
|
@@ -2346,13 +2662,11 @@ module Insika
|
|
|
2346
2662
|
# (does not re-save — the CheckpointStore's monotonicity would raise). It emits
|
|
2347
2663
|
# NO event (:checkpoint_created is stage 8's only) and touches no side-effects.
|
|
2348
2664
|
def save_initial_checkpoint(task, profile, state)
|
|
2349
|
-
return unless @checkpoint_store.find(task.id, turn: state.turn).nil?
|
|
2350
|
-
|
|
2351
2665
|
@checkpoint_store.save(Insika::Checkpoint.new(
|
|
2352
2666
|
task_id: task.id, turn: state.turn, session_id: task.session_id,
|
|
2353
2667
|
agent_id: profile.id, messages: flatten_history(state.context.history),
|
|
2354
2668
|
completed_side_effects: [], created_at: Time.now.utc.iso8601
|
|
2355
|
-
))
|
|
2669
|
+
), if_absent: true)
|
|
2356
2670
|
end
|
|
2357
2671
|
|
|
2358
2672
|
# GRACEFUL halt: a Middleware short-circuited with a safe reply.
|
|
@@ -2384,8 +2698,9 @@ module Insika
|
|
|
2384
2698
|
persist_turn(task, profile, state, content, reply_origin: MessageOrigin::ENGINE,
|
|
2385
2699
|
session: state.guardrail_block&.[](:source) != "edge", timing: timing)
|
|
2386
2700
|
data = { task_id: task.id, content: content, usage: state.usage }
|
|
2387
|
-
|
|
2388
|
-
|
|
2701
|
+
exposed = timing&.to_h
|
|
2702
|
+
data[:timing] = exposed unless exposed.nil? || exposed.empty? # a channel halt still measured
|
|
2703
|
+
persist_turn_timing(task, timing, profile, state.usage)
|
|
2389
2704
|
emit(:task_completed, data, task: task)
|
|
2390
2705
|
end
|
|
2391
2706
|
|
|
@@ -2393,9 +2708,12 @@ module Insika
|
|
|
2393
2708
|
# The record gains `timing` once, when the turn completes; a store failure
|
|
2394
2709
|
# here is swallowed — the turn is already committed and the event already
|
|
2395
2710
|
# carries the number.
|
|
2396
|
-
|
|
2711
|
+
# The full windows also go to ModelMetricsStore as the turn's row (agent,
|
|
2712
|
+
# model, provider), flag or not.
|
|
2713
|
+
def persist_turn_timing(task, timing, profile, usage)
|
|
2397
2714
|
return unless timing
|
|
2398
2715
|
|
|
2716
|
+
record_turn_metrics(task, timing, profile, usage)
|
|
2399
2717
|
hash = timing.to_h
|
|
2400
2718
|
return if hash.empty?
|
|
2401
2719
|
|
|
@@ -2404,6 +2722,28 @@ module Insika
|
|
|
2404
2722
|
nil
|
|
2405
2723
|
end
|
|
2406
2724
|
|
|
2725
|
+
def record_turn_metrics(task, timing, profile, usage, status: "completed", stage: nil)
|
|
2726
|
+
return unless @model_metrics_store.respond_to?(:record_turn)
|
|
2727
|
+
|
|
2728
|
+
usage ||= {}
|
|
2729
|
+
@model_metrics_store.record_turn(task_id: task.id, row: timing.metrics.merge(
|
|
2730
|
+
"at" => Time.now.utc.iso8601(6), "agent" => profile.id.to_s, "status" => status,
|
|
2731
|
+
"stage" => stage&.to_s, "model" => usage[:model]&.to_s, "provider" => usage[:provider]&.to_s
|
|
2732
|
+
).compact)
|
|
2733
|
+
end
|
|
2734
|
+
|
|
2735
|
+
# A failed turn keeps its row too: its total_ms runs to the failure, so a
|
|
2736
|
+
# turn timeout stays in the p95 instead of vanishing from it.
|
|
2737
|
+
def record_failed_turn(task, profile, usage, stage)
|
|
2738
|
+
timing = TurnTiming.current
|
|
2739
|
+
return unless timing && profile
|
|
2740
|
+
|
|
2741
|
+
timing.mark(:done)
|
|
2742
|
+
record_turn_metrics(task, timing, profile, usage, status: "failed", stage: stage)
|
|
2743
|
+
rescue StandardError
|
|
2744
|
+
nil # metrics never fail the failure path
|
|
2745
|
+
end
|
|
2746
|
+
|
|
2407
2747
|
# Emits one :guardrail_flagged per flag the OutputValidator appended in
|
|
2408
2748
|
# after_task (audit only — the turn already completed). Reads a plain Array off
|
|
2409
2749
|
# the state, keeping the Executor decoupled from Safety. an
|
|
@@ -2436,9 +2776,27 @@ module Insika
|
|
|
2436
2776
|
# So a long session legitimately has a Checkpoint SHORTER than the Session:
|
|
2437
2777
|
# that is not drift to reconcile — it is the point. Do NOT "fix" the checkpoint
|
|
2438
2778
|
# to carry the full history (it would defeat the budget) nor evict the session.
|
|
2779
|
+
# A customer confirmation lives for exactly one more customer message: a hold
|
|
2780
|
+
# an EARLIER task of this session left open, and this turn neither confirmed
|
|
2781
|
+
# nor cancelled, is rejected here, once the turn is durable. Fail-closed — a
|
|
2782
|
+
# customer who changed the subject did not agree. Best-effort like the other
|
|
2783
|
+
# stage-8 side writes: a store failure must not re-fail a committed turn.
|
|
2784
|
+
def expire_customer_confirmations(task)
|
|
2785
|
+
return unless @pending_action_store && task.session_id
|
|
2786
|
+
|
|
2787
|
+
expired = @pending_action_store.expire_customer_holds(session_id: task.session_id, except_task_id: task.id)
|
|
2788
|
+
expired.each { |pa| emit(:confirmation_expired, { pending_id: pa.id, tool: pa.tool }, task: task) }
|
|
2789
|
+
rescue StandardError
|
|
2790
|
+
nil
|
|
2791
|
+
end
|
|
2792
|
+
|
|
2439
2793
|
def persist_turn(task, profile, state, content, session: true, reply_origin: nil, timing: nil)
|
|
2440
2794
|
new_messages = turn_transcript(state, content, origin: command_origin(task), reply_origin: reply_origin)
|
|
2441
|
-
|
|
2795
|
+
# Early edge refusal has no built context; retain the existing history in
|
|
2796
|
+
# its checkpoint while keeping the refusal out of the session transcript.
|
|
2797
|
+
history = state.context&.history || command_history(task) ||
|
|
2798
|
+
(task.session_id && @session_store.find(task.session_id)&.messages)
|
|
2799
|
+
transcript = flatten_history(history) + new_messages
|
|
2442
2800
|
|
|
2443
2801
|
@checkpoint_store.save(Insika::Checkpoint.new(
|
|
2444
2802
|
task_id: task.id, turn: state.turn + 1, session_id: task.session_id,
|
|
@@ -2449,12 +2807,26 @@ module Insika
|
|
|
2449
2807
|
# session only when the turn is from a persisted session; one-shot/history
|
|
2450
2808
|
# do not persist TO THE SESSION (but always checkpoint). `session: false` is
|
|
2451
2809
|
# the edge-blocked halt (see complete_with_halt).
|
|
2452
|
-
|
|
2810
|
+
# task_id on each persisted message ties it to the turn's traces (the
|
|
2811
|
+
# Studio's "why this reply" reads it); history replay ignores the key.
|
|
2812
|
+
if session && task.session_id
|
|
2813
|
+
@session_store.append_messages(task.session_id, new_messages.map { |m| m.merge("task_id" => task.id) },
|
|
2814
|
+
agent: profile.id)
|
|
2815
|
+
end
|
|
2816
|
+
|
|
2817
|
+
if (shared = @shared_turns[task.id])
|
|
2818
|
+
if @pending_action_store && task.session_id
|
|
2819
|
+
@pending_action_store.expire_customer_holds(session_id: task.session_id, except_task_id: task.id)
|
|
2820
|
+
end
|
|
2821
|
+
pending = @pending_action_store && task.session_id && !@pending_action_store.open_for_session(task.session_id).empty?
|
|
2822
|
+
@shared_conversations.complete(task: task, messages: new_messages, pending_approval: !!pending,
|
|
2823
|
+
output_parts: state.output_parts, attachments: delivery_attachments(state))
|
|
2824
|
+
shared[:committed] = true
|
|
2825
|
+
emit(:content, { delta: content }, task: task) unless content.empty?
|
|
2826
|
+
end
|
|
2453
2827
|
|
|
2454
|
-
#
|
|
2455
|
-
|
|
2456
|
-
@task_store.finish_execution(task.id, outcome: :completed)
|
|
2457
|
-
@task_store.transition(task.id, to: :completed)
|
|
2828
|
+
# closes the Execution and moves to :completed in one write.
|
|
2829
|
+
@task_store.complete_execution(task.id, outcome: :completed)
|
|
2458
2830
|
# prune is best-effort cleanup: a failure here must NOT re-fail an
|
|
2459
2831
|
# already-committed turn (the task is already :completed and durable).
|
|
2460
2832
|
# Swallow.
|
|
@@ -2480,13 +2852,13 @@ module Insika
|
|
|
2480
2852
|
# path — the user already has the answer above. Same terminal hook,
|
|
2481
2853
|
# next door to the other two, for the same reason: it fires for a fresh
|
|
2482
2854
|
# turn and a recovered one.
|
|
2483
|
-
finalize_knowledge_extraction(task, profile, new_messages)
|
|
2855
|
+
finalize_knowledge_extraction(task, profile, new_messages, state)
|
|
2484
2856
|
|
|
2485
2857
|
# in-session compaction (RFC-0044): when the uncompacted
|
|
2486
2858
|
# transcript crossed the threshold this turn, summarize the old prefix
|
|
2487
2859
|
# with the cheap model and move the boundary — off the critical path,
|
|
2488
2860
|
# same terminal hook, same best-effort discipline as the other three.
|
|
2489
|
-
finalize_compaction(task, profile)
|
|
2861
|
+
finalize_compaction(task, profile, state.chat)
|
|
2490
2862
|
end
|
|
2491
2863
|
|
|
2492
2864
|
# Records the answer in the outbox and dispatches it. The discriminator is the
|
|
@@ -2515,7 +2887,11 @@ module Insika
|
|
|
2515
2887
|
|
|
2516
2888
|
# the hoarded evidence attachments ride the channel delivery
|
|
2517
2889
|
# (additive outbox payload key — the channel contract widens, nothing breaks).
|
|
2518
|
-
|
|
2890
|
+
# When the model SELECTED cards with a presentation tool this turn, the
|
|
2891
|
+
# selection is what rides — in call order, each card stamped with the
|
|
2892
|
+
# component and title of the call that picked it. No selection = every
|
|
2893
|
+
# hoarded card, as before.
|
|
2894
|
+
attachments = delivery_attachments(state)
|
|
2519
2895
|
deliveries = @channel_delivery.record_balloons(
|
|
2520
2896
|
task: task, channel_id: channel_id, content: content,
|
|
2521
2897
|
progressive: @channel_delivery.progressive?(channel_id),
|
|
@@ -2529,6 +2905,17 @@ module Insika
|
|
|
2529
2905
|
nil
|
|
2530
2906
|
end
|
|
2531
2907
|
|
|
2908
|
+
def delivery_attachments(state)
|
|
2909
|
+
presentations = state.respond_to?(:presentations) ? Array(state.presentations) : []
|
|
2910
|
+
if presentations.empty?
|
|
2911
|
+
return state.respond_to?(:evidence_attachments) ? state.evidence_attachments : nil
|
|
2912
|
+
end
|
|
2913
|
+
|
|
2914
|
+
presentations.flat_map do |p|
|
|
2915
|
+
Array(p["items"]).map { |item| item.merge("component" => p["component"], "title" => p["title"]).compact }
|
|
2916
|
+
end
|
|
2917
|
+
end
|
|
2918
|
+
|
|
2532
2919
|
# Turns whose combined transcript slice is this trivially short skip
|
|
2533
2920
|
# extraction entirely ("ok thanks" exchanges) — no new config surface,
|
|
2534
2921
|
# just avoids a wasted utility-model call.
|
|
@@ -2541,22 +2928,27 @@ module Insika
|
|
|
2541
2928
|
# boot sweep), a child of the turn supervisor when serving (survives the
|
|
2542
2929
|
# request's own disconnect). Best-effort: any failure is swallowed here,
|
|
2543
2930
|
# never re-fails an already-committed turn.
|
|
2544
|
-
def finalize_knowledge_extraction(task, profile, new_messages)
|
|
2931
|
+
def finalize_knowledge_extraction(task, profile, new_messages, state = nil)
|
|
2545
2932
|
return unless @knowledge_store
|
|
2933
|
+
return if simulated_session?(task.session_id)
|
|
2546
2934
|
|
|
2547
2935
|
config = Coercion.deep_stringify(profile.knowledge)
|
|
2548
2936
|
return unless config && Coercion.truthy?(config["extract"])
|
|
2549
2937
|
return if knowledge_transcript(new_messages).length < KNOWLEDGE_MIN_CHARS
|
|
2550
2938
|
|
|
2551
|
-
extractor = Knowledge::ExtractorFactory.build(config, utility_model: utility_model
|
|
2939
|
+
extractor = Knowledge::ExtractorFactory.build(config, utility_model: utility_model,
|
|
2940
|
+
llm: llm_operation_context(state, nil, operation: "knowledge_extract"))
|
|
2552
2941
|
return unless extractor
|
|
2553
2942
|
|
|
2554
2943
|
# Same resolved model as the extractor — a deployment names one
|
|
2555
2944
|
# knowledge model, not two. nil consolidator (no model resolvable) is
|
|
2556
2945
|
# still meaningful: write_concept's conservative default.
|
|
2557
|
-
consolidator = Knowledge::ConsolidatorFactory.build(config, utility_model: utility_model
|
|
2946
|
+
consolidator = Knowledge::ConsolidatorFactory.build(config, utility_model: utility_model,
|
|
2947
|
+
llm: llm_operation_context(state, nil, operation: "knowledge_consolidate"))
|
|
2558
2948
|
|
|
2559
|
-
|
|
2949
|
+
context = state&.context
|
|
2950
|
+
agent_brief = context.respond_to?(:system_identity) ? context.system_identity : nil
|
|
2951
|
+
run = lambda { run_knowledge_extraction(task, profile, config, new_messages, extractor, consolidator, agent_brief) }
|
|
2560
2952
|
return run.call unless @supervised
|
|
2561
2953
|
|
|
2562
2954
|
turn_parent.async do |t|
|
|
@@ -2565,10 +2957,17 @@ module Insika
|
|
|
2565
2957
|
end
|
|
2566
2958
|
end
|
|
2567
2959
|
|
|
2568
|
-
def run_knowledge_extraction(task, profile, config, new_messages, extractor, consolidator)
|
|
2569
|
-
prompt = knowledge_prompt(config, new_messages)
|
|
2960
|
+
def run_knowledge_extraction(task, profile, config, new_messages, extractor, consolidator, agent_brief = nil)
|
|
2961
|
+
prompt = knowledge_prompt(config, new_messages, agent_brief)
|
|
2570
2962
|
result = extractor.extract(prompt: prompt)
|
|
2571
2963
|
result[:concepts].each do |concept|
|
|
2964
|
+
if profile.shared_conversations
|
|
2965
|
+
redacted, = Safety::Detectors.redact(concept['body'].to_s)
|
|
2966
|
+
@shared_conversations.propose_memory(task:task,id:concept['name'],kind:'knowledge',
|
|
2967
|
+
value:Knowledge.first_sighting(concept,redacted,rebuild_command(task).payload.dig('shared_conversation','conversation_id')))
|
|
2968
|
+
emit(:knowledge_conflict, {name:concept['name'],agent:profile.id}, task:task)
|
|
2969
|
+
next
|
|
2970
|
+
end
|
|
2572
2971
|
outcome = Knowledge.write_concept(
|
|
2573
2972
|
store: @knowledge_store, agent_id: profile.id, concept: concept, session_id: task.session_id,
|
|
2574
2973
|
tenant: task_tenant(task), consolidator: consolidator
|
|
@@ -2591,26 +2990,32 @@ module Insika
|
|
|
2591
2990
|
end
|
|
2592
2991
|
end
|
|
2593
2992
|
|
|
2594
|
-
|
|
2993
|
+
# Transcripts drop tool results (product cards, search hits), so without the
|
|
2994
|
+
# agent's own identity the extractor guesses the domain: "pode ser importado"
|
|
2995
|
+
# about a perfume became "imported vehicles" for a beauty store.
|
|
2996
|
+
KNOWLEDGE_BRIEF_CHARS = 1500
|
|
2997
|
+
|
|
2998
|
+
def knowledge_prompt(config, new_messages, agent_brief = nil)
|
|
2595
2999
|
base = Coercion.presence(config["prompt"]) || Knowledge::DEFAULT_PROMPT
|
|
3000
|
+
# ponytail: the head of the identity prompt names the store in our packs; a pack whose prompt
|
|
3001
|
+
# opens elsewhere needs a `knowledge.prompt` that states the domain.
|
|
3002
|
+
brief = Coercion.presence(agent_brief.to_s[0, KNOWLEDGE_BRIEF_CHARS])
|
|
3003
|
+
agent = brief ? "\n## The agent (read for its business domain only)\n\n#{brief}\n" : ""
|
|
2596
3004
|
<<~PROMPT
|
|
2597
3005
|
#{base.rstrip}
|
|
2598
|
-
|
|
3006
|
+
#{agent}
|
|
2599
3007
|
## The conversation
|
|
2600
3008
|
|
|
2601
3009
|
#{knowledge_transcript(new_messages)}
|
|
2602
3010
|
PROMPT
|
|
2603
3011
|
end
|
|
2604
3012
|
|
|
2605
|
-
#
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
)
|
|
2612
|
-
redacted
|
|
2613
|
-
end
|
|
3013
|
+
# Synthetic traffic must not teach the production agent (SessionStore.synthetic?).
|
|
3014
|
+
def simulated_session?(session_id) = Insika::SessionStore.synthetic?(session_id)
|
|
3015
|
+
|
|
3016
|
+
# Only what people said, PII-redacted — a `role: tool` message is
|
|
3017
|
+
# third-party text and never a source of a learned concept.
|
|
3018
|
+
def knowledge_transcript(new_messages) = Insika::SpokenTranscript.render(new_messages)
|
|
2614
3019
|
|
|
2615
3020
|
def utility_model
|
|
2616
3021
|
return nil unless @settings_store
|
|
@@ -2618,6 +3023,14 @@ module Insika
|
|
|
2618
3023
|
@settings_store.get["utility_model"]
|
|
2619
3024
|
end
|
|
2620
3025
|
|
|
3026
|
+
# `Settings fencing.max_chars`, the per-leaf cap the envelope applies for a
|
|
3027
|
+
# fenced agent. One settings read per fenced turn, like the queue policy.
|
|
3028
|
+
def fence_max_chars
|
|
3029
|
+
config = @settings_store && Coercion.deep_stringify(@settings_store.get["fencing"])
|
|
3030
|
+
n = config && config["max_chars"].to_i
|
|
3031
|
+
n && n.positive? ? n : Insika::Fence::DEFAULT_MAX_CHARS
|
|
3032
|
+
end
|
|
3033
|
+
|
|
2621
3034
|
# in-session compaction (RFC-0044). Platform-gated
|
|
2622
3035
|
# (Settings compaction.enabled — parity when off), planned over the
|
|
2623
3036
|
# POST-append transcript (persist_turn already wrote this turn's messages),
|
|
@@ -2625,7 +3038,7 @@ module Insika
|
|
|
2625
3038
|
# finalize_knowledge_extraction: inline when non-supervised, a child of the
|
|
2626
3039
|
# turn supervisor when serving. Best-effort: any failure leaves the session
|
|
2627
3040
|
# record untouched and the next turn re-plans.
|
|
2628
|
-
def finalize_compaction(task, profile)
|
|
3041
|
+
def finalize_compaction(task, profile, chat = nil)
|
|
2629
3042
|
return unless task.session_id && @settings_store
|
|
2630
3043
|
|
|
2631
3044
|
config = Coercion.deep_stringify(@settings_store.get["compaction"])
|
|
@@ -2635,16 +3048,29 @@ module Insika
|
|
|
2635
3048
|
return unless session
|
|
2636
3049
|
|
|
2637
3050
|
state = session.respond_to?(:compaction) ? session.compaction : nil
|
|
3051
|
+
native = config["mode"] == "native"
|
|
3052
|
+
binding = Compaction.native_binding(chat) if native
|
|
3053
|
+
return if native && !binding
|
|
3054
|
+
|
|
3055
|
+
# A portable summary must start from the original prefix, not an opaque
|
|
3056
|
+
# native marker, when the operator switches compaction modes.
|
|
3057
|
+
state = nil if !native && state && state["native"]
|
|
2638
3058
|
plan = Insika::Compaction.plan(messages: session.messages, state: state, config: config)
|
|
2639
3059
|
return unless plan
|
|
2640
3060
|
|
|
2641
3061
|
# compaction.model -> platform utility_model -> inert (never a guess);
|
|
2642
3062
|
# `insika doctor` warns on enabled-with-no-model. @llm rides along so a
|
|
2643
3063
|
# deployment (or spec) that injects its LLM seam covers this call too.
|
|
2644
|
-
|
|
2645
|
-
|
|
3064
|
+
if native
|
|
3065
|
+
run = lambda { run_native_compaction(task, profile, session, plan, chat, binding) }
|
|
3066
|
+
else
|
|
3067
|
+
llm = @llm || (defined?(RubyLLM) && RubyLLM)
|
|
3068
|
+
llm = instrumented_llm(task, nil, operation: "compaction", agent: profile.id) if llm
|
|
3069
|
+
summarizer = Compaction::SummarizerFactory.build(config, utility_model: utility_model, llm: llm)
|
|
3070
|
+
return unless summarizer
|
|
2646
3071
|
|
|
2647
|
-
|
|
3072
|
+
run = lambda { run_compaction(task, profile, session, state, plan, config, summarizer) }
|
|
3073
|
+
end
|
|
2648
3074
|
return run.call unless @supervised
|
|
2649
3075
|
|
|
2650
3076
|
turn_parent.async do |t|
|
|
@@ -2653,6 +3079,27 @@ module Insika
|
|
|
2653
3079
|
end
|
|
2654
3080
|
end
|
|
2655
3081
|
|
|
3082
|
+
def run_native_compaction(task, profile, session, plan, source, binding)
|
|
3083
|
+
# Rebuild from persisted, filtered messages; never copy the live chat's raw output.
|
|
3084
|
+
chat = source.context.chat(model: source.model.id, provider: binding.fetch("provider"),
|
|
3085
|
+
protocol: :responses, assume_model_exists: true)
|
|
3086
|
+
@chat_builder.seed_history(chat, session.messages.take(plan.upto))
|
|
3087
|
+
message = chat.compact
|
|
3088
|
+
raw = message.raw_content
|
|
3089
|
+
return unless Compaction.native_payload?(raw)
|
|
3090
|
+
|
|
3091
|
+
native = { "binding" => binding, "message" => {
|
|
3092
|
+
"role" => "assistant", "content" => "", "raw_content" => raw
|
|
3093
|
+
} }
|
|
3094
|
+
updated = @session_store.set_compaction(task.session_id, native: native,
|
|
3095
|
+
upto: plan.upto, model: binding.fetch("model"))
|
|
3096
|
+
emit(:context_compacted, { task_id: task.id, agent: profile.id, from: 0, upto: plan.upto,
|
|
3097
|
+
messages: plan.upto, runs: updated.compaction["runs"], model: binding.fetch("model"),
|
|
3098
|
+
usage: usage_of(chat) }, task: task)
|
|
3099
|
+
rescue StandardError
|
|
3100
|
+
nil # A failed compaction cannot fail the already-committed turn.
|
|
3101
|
+
end
|
|
3102
|
+
|
|
2656
3103
|
def run_compaction(task, profile, session, state, plan, config, summarizer)
|
|
2657
3104
|
prompt = Insika::Compaction.prompt(messages: session.messages, plan: plan,
|
|
2658
3105
|
previous: state && state["summary"],
|
|
@@ -2712,28 +3159,39 @@ module Insika
|
|
|
2712
3159
|
def turn_transcript(state, content, origin: nil, reply_origin: nil)
|
|
2713
3160
|
recorded = recorded_turn_messages(state)
|
|
2714
3161
|
if recorded.empty?
|
|
2715
|
-
return [MessageOrigin.stamp({ "role" => "user", "content" => state
|
|
3162
|
+
return [stamp_guardrail_flags(MessageOrigin.stamp({ "role" => "user", "content" => ask_text(state) }, origin), state),
|
|
2716
3163
|
MessageOrigin.stamp({ "role" => "assistant", "content" => content.to_s }, reply_origin)]
|
|
2717
3164
|
end
|
|
2718
3165
|
|
|
2719
|
-
recorded[0] = MessageOrigin.stamp(recorded[0], origin) if recorded[0]["role"] == "user"
|
|
3166
|
+
recorded[0] = stamp_guardrail_flags(MessageOrigin.stamp(recorded[0], origin), state) if recorded[0]["role"] == "user"
|
|
2720
3167
|
recorded[-1] = recorded[-1].merge("content" => content.to_s) if recorded.last["role"] == "assistant"
|
|
2721
3168
|
recorded
|
|
2722
3169
|
end
|
|
2723
3170
|
|
|
3171
|
+
# Flags raised on the INCOMING message (the input guardrail runs before the
|
|
3172
|
+
# model; output flags come after persist) ride on that message, so the
|
|
3173
|
+
# transcript shows them after the live event stream is gone. Never sent to the
|
|
3174
|
+
# model: history replay reads role/content/tool_calls only.
|
|
3175
|
+
def stamp_guardrail_flags(message, state)
|
|
3176
|
+
flags = Array(state.guardrail_flags)
|
|
3177
|
+
return message if flags.empty?
|
|
3178
|
+
|
|
3179
|
+
message.merge("guardrail_flags" => flags.map { |f| f.transform_keys(&:to_s) })
|
|
3180
|
+
end
|
|
3181
|
+
|
|
2724
3182
|
# Slices the chat's messages added DURING this turn and serializes them.
|
|
2725
3183
|
# [] when there is no recorded transcript (baseline nil / no #messages).
|
|
2726
|
-
def recorded_turn_messages(state)
|
|
2727
|
-
chat = state.chat
|
|
3184
|
+
def recorded_turn_messages(state, chat: state.chat)
|
|
2728
3185
|
baseline = state.chat_baseline
|
|
2729
3186
|
return [] unless chat && baseline && chat.respond_to?(:messages)
|
|
2730
3187
|
|
|
2731
|
-
Array(chat.messages).drop(baseline).filter_map { |m| serialize_chat_message(m) }
|
|
3188
|
+
Array(chat.messages).drop(baseline).filter_map { |m| serialize_chat_message(m, clip: !(state.task && @shared_turns.key?(state.task.id))) }
|
|
2732
3189
|
end
|
|
2733
3190
|
|
|
2734
3191
|
# A RubyLLM::Message (duck-typed) -> string-keyed Hash. Assistant carries
|
|
2735
3192
|
# "tool_calls" only when present; tool carries "tool_call_id" + a clipped content.
|
|
2736
|
-
|
|
3193
|
+
# Do not persist raw_content: native replay would bypass redaction and clipping.
|
|
3194
|
+
def serialize_chat_message(msg, clip: true)
|
|
2737
3195
|
role = msg_field(msg, :role).to_s
|
|
2738
3196
|
content = msg_field(msg, :content).to_s
|
|
2739
3197
|
case role
|
|
@@ -2744,7 +3202,7 @@ module Insika
|
|
|
2744
3202
|
h
|
|
2745
3203
|
when "tool"
|
|
2746
3204
|
{ "role" => "tool", "tool_call_id" => msg_field(msg, :tool_call_id).to_s,
|
|
2747
|
-
"content" => clip_tool_content(content) }
|
|
3205
|
+
"content" => clip ? clip_tool_content(content) : content }
|
|
2748
3206
|
else
|
|
2749
3207
|
{ "role" => role, "content" => content }
|
|
2750
3208
|
end
|
|
@@ -2793,6 +3251,7 @@ module Insika
|
|
|
2793
3251
|
require_relative "tools/load_knowledge"
|
|
2794
3252
|
require_relative "tools/tool_search"
|
|
2795
3253
|
require_relative "tools/remember"
|
|
3254
|
+
require_relative "tools/confirm_pending"
|
|
2796
3255
|
require_relative "tools/subagent"
|
|
2797
3256
|
require_relative "tools/subagents"
|
|
2798
3257
|
require_relative "tools/stuck_signal"
|
|
@@ -2807,16 +3266,17 @@ module Insika
|
|
|
2807
3266
|
# enforced, fallback chain resolved. Kept on the state for telemetry (usage).
|
|
2808
3267
|
selection = @model_resolver.resolve(profile: profile, session: state.session)
|
|
2809
3268
|
state.model_selection = selection
|
|
2810
|
-
build_chat(selection, selection)
|
|
3269
|
+
build_chat(selection, selection, state: state)
|
|
2811
3270
|
end
|
|
2812
3271
|
|
|
2813
3272
|
# The gem boundary: one chat for a model selection (the resolved primary or
|
|
2814
3273
|
# a WS3 fallback node). The primary's generation params apply to the whole
|
|
2815
3274
|
# chain (params_source: ModelSelection#apply_params).
|
|
2816
|
-
def build_chat(selection, params_source)
|
|
3275
|
+
def build_chat(selection, params_source, state: nil)
|
|
2817
3276
|
model = selection.respond_to?(:model) ? selection.model : selection[:model]
|
|
2818
3277
|
provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
|
|
2819
|
-
|
|
3278
|
+
@llm_refresh&.call
|
|
3279
|
+
chat = llm_operation_context(state, model).chat(
|
|
2820
3280
|
model: model,
|
|
2821
3281
|
provider: provider,
|
|
2822
3282
|
assume_model_exists: !provider.nil?
|
|
@@ -2825,6 +3285,62 @@ module Insika
|
|
|
2825
3285
|
chat
|
|
2826
3286
|
end
|
|
2827
3287
|
|
|
3288
|
+
# `operation` labels every request this context makes in the task's traces and
|
|
3289
|
+
# model metrics: "chat" for the answer, knowledge_* for the post-turn calls, so
|
|
3290
|
+
# the Models page can tell the reply's cost from the learning's.
|
|
3291
|
+
def llm_operation_context(state, model, operation: "chat")
|
|
3292
|
+
return @llm || RubyLLM unless state.respond_to?(:task) && state.task
|
|
3293
|
+
|
|
3294
|
+
instrumented_llm(state.task, model, operation: operation, turn: state.turn,
|
|
3295
|
+
agent: state.respond_to?(:profile) ? state.profile&.id : nil)
|
|
3296
|
+
end
|
|
3297
|
+
|
|
3298
|
+
# Every request a task makes, whatever the caller: rows carry the agent, so
|
|
3299
|
+
# the Models page filters by it.
|
|
3300
|
+
def instrumented_llm(task, model, operation:, turn: nil, agent: nil)
|
|
3301
|
+
source = @llm || RubyLLM
|
|
3302
|
+
return source unless source.respond_to?(:config)
|
|
3303
|
+
|
|
3304
|
+
require_relative "telemetry/ruby_llm_instrumenter"
|
|
3305
|
+
config = source.config.dup
|
|
3306
|
+
extra = { "turn" => turn, "agent" => agent&.to_s }.compact
|
|
3307
|
+
config.instrumenter = Telemetry::RubyLLMInstrumenter.new(
|
|
3308
|
+
delegate: config.instrumenter, operation: operation, model: model, pricing: @pricing,
|
|
3309
|
+
emit: ->(type, data) { emit(type, data.merge(extra), task: task) }
|
|
3310
|
+
)
|
|
3311
|
+
RubyLLM::Context.new(config)
|
|
3312
|
+
end
|
|
3313
|
+
|
|
3314
|
+
# A turn's LLM diagnostics in one batch per store, instead of one transaction
|
|
3315
|
+
# per provider event: written when the turn ends (however it ends, after the
|
|
3316
|
+
# customer already has the answer) and before it suspends for a human.
|
|
3317
|
+
def write_llm_diagnostics(task, entries)
|
|
3318
|
+
return if entries.nil? || entries.empty?
|
|
3319
|
+
|
|
3320
|
+
[@llm_trace_store, @model_metrics_store].each do |store|
|
|
3321
|
+
next unless store
|
|
3322
|
+
|
|
3323
|
+
if store.respond_to?(:record_many)
|
|
3324
|
+
store.record_many(task_id: task.id, entries: entries)
|
|
3325
|
+
else
|
|
3326
|
+
entries.each { |entry| store.record(task_id: task.id, entry: entry) }
|
|
3327
|
+
end
|
|
3328
|
+
rescue StandardError
|
|
3329
|
+
# Diagnostics must never fail the turn.
|
|
3330
|
+
end
|
|
3331
|
+
end
|
|
3332
|
+
|
|
3333
|
+
# Writes what the running turn buffered so far and keeps buffering: a turn
|
|
3334
|
+
# about to wait (approval, pause) may wait for hours, and its cost so far
|
|
3335
|
+
# must be visible and survive a crash meanwhile.
|
|
3336
|
+
def flush_llm_diagnostics(task)
|
|
3337
|
+
entries = @llm_buffers[task.id]
|
|
3338
|
+
return if entries.nil? || entries.empty?
|
|
3339
|
+
|
|
3340
|
+
@llm_buffers[task.id] = []
|
|
3341
|
+
write_llm_diagnostics(task, entries)
|
|
3342
|
+
end
|
|
3343
|
+
|
|
2828
3344
|
# Single emitter: an Event with meta and a monotonic seq per task. @seqs is not
|
|
2829
3345
|
# cleared at the end of the task — the resume (new Execution) continues the
|
|
2830
3346
|
# numbering (reliable replay). A task WITH a tenant (WS1) tags every event it
|
|
@@ -2832,10 +3348,28 @@ module Insika
|
|
|
2832
3348
|
# event without a task has no tenant and never matches a tenant stream);
|
|
2833
3349
|
# absent tenant -> the meta is byte-identical to before.
|
|
2834
3350
|
def emit(type, data, task:)
|
|
3351
|
+
# Required mode releases only the accepted final answer after its durable ack.
|
|
3352
|
+
if (shared = @shared_turns[task.id]) && !shared[:committed]
|
|
3353
|
+
return if %i[content intermediate thinking workflow_completed].include?(type)
|
|
3354
|
+
end
|
|
2835
3355
|
meta = { task_id: task.id, session_id: task.session_id,
|
|
2836
|
-
seq: (@seqs[task.id] += 1), at: Time.now.utc.iso8601 }
|
|
3356
|
+
seq: (@seqs[task.id] += 1), at: Time.now.utc.iso8601(6) }
|
|
2837
3357
|
tenant = task_tenant(task)
|
|
2838
3358
|
meta[:tenant] = tenant unless tenant.nil?
|
|
3359
|
+
if type == :llm_request || type == :llm_usage
|
|
3360
|
+
entry = data.merge("type" => type.to_s, "at" => meta[:at])
|
|
3361
|
+
if (buffer = @llm_buffers[task.id])
|
|
3362
|
+
buffer << entry # a running turn writes them all at its end (#flush_llm_diagnostics)
|
|
3363
|
+
else
|
|
3364
|
+
[@llm_trace_store, @model_metrics_store].each do |store|
|
|
3365
|
+
begin
|
|
3366
|
+
store&.record(task_id: task.id, entry: entry)
|
|
3367
|
+
rescue StandardError
|
|
3368
|
+
# Recorders fail independently; diagnostics must not interrupt the model.
|
|
3369
|
+
end
|
|
3370
|
+
end
|
|
3371
|
+
end
|
|
3372
|
+
end
|
|
2839
3373
|
@event_stream.emit(Insika::Event.new(type: type, data: data, meta: meta))
|
|
2840
3374
|
end
|
|
2841
3375
|
|