insika 0.9.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 +237 -2
- data/README.md +5 -3
- data/bin/insika +3 -0
- data/bin/insika-router +0 -1
- data/docs/AGENTS.md +90 -7
- data/docs/ARCHITECTURE.md +12 -7
- data/docs/ARTIFACTS.md +33 -3
- data/docs/BENCHMARK.md +50 -7
- data/docs/CONTEXT.md +63 -0
- data/docs/CROSS-HARNESS-BENCH.md +157 -0
- data/docs/DEPLOY.md +57 -11
- data/docs/EMBEDDING.md +10 -0
- data/docs/EVALS.md +33 -4
- data/docs/FACTS.md +8 -6
- data/docs/HARVEST.md +8 -3
- data/docs/KNOWLEDGE.md +7 -0
- data/docs/MEDIA.md +6 -2
- data/docs/OBSERVABILITY.md +143 -22
- data/docs/PERFORMANCE.md +110 -0
- data/docs/PLUGINS.md +4 -4
- data/docs/POLICY.md +22 -13
- data/docs/RELEASING.md +5 -0
- data/docs/ROUTER.md +7 -0
- data/docs/RUBYLLM_2_MIGRATION.md +307 -0
- data/docs/SECURITY.md +7 -0
- data/docs/SHARED-CONVERSATIONS.md +40 -0
- data/docs/SKILLS.md +5 -3
- data/docs/STUDIO.md +122 -0
- data/docs/TOOLS.md +111 -9
- data/docs/operate.md +3 -0
- data/lib/insika/agent_defaults.rb +140 -0
- data/lib/insika/agent_file_store.rb +12 -0
- data/lib/insika/agent_profile.rb +78 -8
- data/lib/insika/artifact_signing.rb +11 -0
- data/lib/insika/chat_builder.rb +79 -25
- 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_harvest.rb +17 -1
- data/lib/insika/commands/seed_session.rb +11 -1
- 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/priority.rb +3 -0
- data/lib/insika/context/provider.rb +2 -2
- data/lib/insika/context/providers/knowledge.rb +25 -2
- data/lib/insika/context/providers/memory.rb +53 -4
- 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 +39 -3
- data/lib/insika/context/reranker.rb +75 -0
- data/lib/insika/distill.rb +22 -13
- data/lib/insika/doctor.rb +34 -4
- data/lib/insika/dsl/runtime.rb +36 -4
- data/lib/insika/dsl/server_boot.rb +4 -12
- data/lib/insika/dsl.rb +13 -5
- data/lib/insika/edge_limiter.rb +23 -15
- data/lib/insika/egress_guard.rb +11 -3
- data/lib/insika/env_schema.rb +33 -73
- data/lib/insika/evals/assertions.rb +171 -16
- data/lib/insika/evals/golden.rb +141 -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 +44 -4
- data/lib/insika/evals/simulator.rb +10 -1
- data/lib/insika/evals/transport.rb +200 -0
- data/lib/insika/event_stream.rb +63 -7
- data/lib/insika/evidence.rb +29 -8
- data/lib/insika/executor.rb +668 -141
- 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 +32 -1
- data/lib/insika/mcp_tool_registry.rb +69 -26
- data/lib/insika/media.rb +38 -25
- data/lib/insika/memory_store.rb +4 -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 +25 -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/profile_source.rb +4 -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 +17 -8
- data/lib/insika/server/responses.rb +19 -1
- data/lib/insika/session_actor.rb +58 -28
- data/lib/insika/session_store.rb +159 -10
- data/lib/insika/settings_store.rb +47 -1
- 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/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 +1453 -161
- 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 +171 -9
- 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 +78 -242
- 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} +32 -9
- 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 +152 -117
- 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 +103 -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_batch.rb +1 -1
- data/lib/insika/tool_catalog.rb +12 -2
- data/lib/insika/tool_definition.rb +10 -4
- data/lib/insika/tool_envelope.rb +165 -28
- data/lib/insika/tool_manifest.rb +1 -0
- data/lib/insika/tool_store.rb +3 -0
- data/lib/insika/tool_trace_store.rb +15 -8
- data/lib/insika/tool_usage_report.rb +2 -2
- 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 +23 -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 +1 -8
- data/lib/insika/tools/remember.rb +7 -2
- data/lib/insika/tools/run_persona_eval.rb +3 -5
- 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 +7 -5
- 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 +18 -0
- data/lib/insika/turn_timing.rb +51 -14
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +81 -16
- data/lib/insika/workflow.rb +25 -64
- data/lib/insika.rb +19 -3
- metadata +69 -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,14 @@ 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)
|
|
916
1009
|
state.fence_max_chars = fence_max_chars if Insika::Fence.enabled?(profile)
|
|
917
1010
|
# resolved for the RUN, not per message, so what the turn accepts cannot
|
|
918
1011
|
# change under it. Same cost as the EdgeLimiter's per-turn resolution. Only for a
|
|
@@ -925,6 +1018,10 @@ module Insika
|
|
|
925
1018
|
# validator/enforcer read the union, stage 8 flushes.
|
|
926
1019
|
state.evidence_ledger = Insika::EvidenceLedger.new(store: @session_store,
|
|
927
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
|
|
928
1025
|
state
|
|
929
1026
|
end
|
|
930
1027
|
|
|
@@ -981,6 +1078,7 @@ module Insika
|
|
|
981
1078
|
# wire the approval gate into the state (the ToolEnvelope reads it at stage 6).
|
|
982
1079
|
state.actor = actor
|
|
983
1080
|
state.approval_coordinator = self
|
|
1081
|
+
state.pending_action_store = @pending_action_store
|
|
984
1082
|
state.requires_approval = resolution.requires_approval
|
|
985
1083
|
state.allowed_tools = wrap_tools(assemble_tool_instances(resolution.allowed_tools, state), state, skip)
|
|
986
1084
|
state.allowed_skills = resolution.allowed_skills
|
|
@@ -1067,7 +1165,7 @@ module Insika
|
|
|
1067
1165
|
# approximating RubyLLM's rendering (honest in the doc: the reason's job is
|
|
1068
1166
|
# the CONTEXT categories; the tool hash is a guard rail).
|
|
1069
1167
|
def serialize_tools(tools)
|
|
1070
|
-
tools.map { |t| "#{t.name} #{t.description} #{t.
|
|
1168
|
+
tools.map { |t| "#{t.name} #{t.description} #{t.parameters_schema.inspect}" }.join(" ")
|
|
1071
1169
|
end
|
|
1072
1170
|
|
|
1073
1171
|
# the stage-8 stamp — the usage (cached_tokens,
|
|
@@ -1147,7 +1245,7 @@ module Insika
|
|
|
1147
1245
|
# Same yardstick as the fragments (TokenEstimator), so the categories are
|
|
1148
1246
|
# comparable. `parameters` is not guaranteed JSON-safe — inspect it.
|
|
1149
1247
|
def estimate_tools_tokens(tools)
|
|
1150
|
-
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(" "))
|
|
1151
1249
|
rescue StandardError
|
|
1152
1250
|
0
|
|
1153
1251
|
end
|
|
@@ -1201,6 +1299,7 @@ module Insika
|
|
|
1201
1299
|
# but is not honored (last stage); :cancel here still raises.
|
|
1202
1300
|
actor.drain!
|
|
1203
1301
|
persist_turn(task, profile, st, content, timing: timing)
|
|
1302
|
+
expire_customer_confirmations(task)
|
|
1204
1303
|
|
|
1205
1304
|
# the cache-hit stamp — the usage exists only now. The
|
|
1206
1305
|
# stamped entry is durable before anything is delivered (same slot as the
|
|
@@ -1221,11 +1320,12 @@ module Insika
|
|
|
1221
1320
|
# Additive sibling — the answer text stays text on purpose; the channel
|
|
1222
1321
|
# consumes the parts next to it. Absent when nothing was generated.
|
|
1223
1322
|
data[:output_parts] = st.output_parts if st.output_parts && !st.output_parts.empty?
|
|
1224
|
-
|
|
1323
|
+
exposed = timing&.to_h
|
|
1324
|
+
data[:timing] = exposed unless exposed.nil? || exposed.empty? # opt-in TTFB breakdown (INSIKA_TURN_TIMING)
|
|
1225
1325
|
# best-effort persist of the same timing onto the task record —
|
|
1226
1326
|
# the Studio task page reads it from there. A failure here must not re-fail
|
|
1227
1327
|
# the turn (the task is already committed and the event already carries it).
|
|
1228
|
-
persist_turn_timing(task, timing)
|
|
1328
|
+
persist_turn_timing(task, timing, profile, st.usage)
|
|
1229
1329
|
# WS5: the agent declared it cannot proceed (signal_stuck). The turn still
|
|
1230
1330
|
# COMPLETES (its final message was published) — but the consumer must be able
|
|
1231
1331
|
# to act on that, so the contract carries it twice: a dedicated :turn_stuck
|
|
@@ -1305,13 +1405,6 @@ module Insika
|
|
|
1305
1405
|
# release the redactor's retained tail (a value that never completed into a
|
|
1306
1406
|
# match is emitted redacted-if-needed, not lost) before reading anything back.
|
|
1307
1407
|
output.flush
|
|
1308
|
-
# MERGED, not overwritten: a WS4 routing call already banked its tokens in
|
|
1309
|
-
# state.usage before the ask — the classifier's cost must survive the ask
|
|
1310
|
-
# (it feeds the EdgeLimiter's ceiling/budget and the terminal usage).
|
|
1311
|
-
unless halted?(response)
|
|
1312
|
-
state.usage = merge_usage(with_model_source(usage_of(response), state.model_selection),
|
|
1313
|
-
state.usage)
|
|
1314
|
-
end
|
|
1315
1408
|
|
|
1316
1409
|
# BOUNDARY BEFORE THE ANSWER GOES OUT. A cancel that arrived while the provider
|
|
1317
1410
|
# was working used to be observed at stage 8 — AFTER `:content` had already been
|
|
@@ -1323,10 +1416,23 @@ module Insika
|
|
|
1323
1416
|
# holding a completed answer for an operator would strand it unpublished.
|
|
1324
1417
|
state.actor&.drain!
|
|
1325
1418
|
|
|
1326
|
-
output.publish(turn_answer(response, asked, output, filter))
|
|
1419
|
+
output.publish(without_prompt_echo(state, turn_answer(response, asked, output, filter)))
|
|
1327
1420
|
end
|
|
1328
1421
|
end
|
|
1329
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
|
+
|
|
1330
1436
|
# stage 6, plain path: the single ask on the assembled chat. Fresh TurnOutput
|
|
1331
1437
|
# + steer wiring per interaction (registered on state.chat, which the solve
|
|
1332
1438
|
# already assembled). -> { response:, asked:, output: }.
|
|
@@ -1337,8 +1443,8 @@ module Insika
|
|
|
1337
1443
|
{ response: asked, asked: asked, output: output }
|
|
1338
1444
|
end
|
|
1339
1445
|
|
|
1340
|
-
#
|
|
1341
|
-
#
|
|
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
|
|
1342
1448
|
# + output (a failed `ask` leaves its message in the chat, so re-asking the
|
|
1343
1449
|
# same one would double the input) and, on a fallback, state.model_selection
|
|
1344
1450
|
# follows — the turn's usage is attributed to the model that actually spoke
|
|
@@ -1350,8 +1456,10 @@ module Insika
|
|
|
1350
1456
|
attempt_output = nil
|
|
1351
1457
|
attempt_asked = nil
|
|
1352
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
|
|
1353
1461
|
response = @reliability.call(
|
|
1354
|
-
policy:
|
|
1462
|
+
policy: coordinator_policy, tenant: task_tenant(task), agent: state.profile.id.to_s,
|
|
1355
1463
|
selection: primary, chain: reliability_chain(state)
|
|
1356
1464
|
) do |selection, tries|
|
|
1357
1465
|
first_attempt = state.chat && selection == primary && tries == 1
|
|
@@ -1362,7 +1470,9 @@ module Insika
|
|
|
1362
1470
|
chat = build_attempt_chat(state, selection)
|
|
1363
1471
|
state.chat = chat
|
|
1364
1472
|
end
|
|
1473
|
+
filter&.flush # Discard the failed stream's retained tail before the next attempt.
|
|
1365
1474
|
attempt_output = new_turn_output(task, state, filter)
|
|
1475
|
+
configure_reliable_chat(state.chat, policy)
|
|
1366
1476
|
wire_chat_output(task, state, attempt_output)
|
|
1367
1477
|
attempt_asked = ask_on(task, state, state.chat, attempt_output, timing)
|
|
1368
1478
|
attempt_asked
|
|
@@ -1370,6 +1480,19 @@ module Insika
|
|
|
1370
1480
|
{ response: response, asked: attempt_asked, output: attempt_output }
|
|
1371
1481
|
end
|
|
1372
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
|
+
|
|
1373
1496
|
# The fallback chain for WS3: profile's `reliability["fallback"]` refs first,
|
|
1374
1497
|
# then the platform-resolved fallbacks (ModelSelection). Each node is a
|
|
1375
1498
|
# ModelSelection (the SAME duck the primary is — usage attribution and
|
|
@@ -1377,6 +1500,8 @@ module Insika
|
|
|
1377
1500
|
# primary. Deduped by ref, primary excluded.
|
|
1378
1501
|
def reliability_chain(state)
|
|
1379
1502
|
primary = state.model_selection
|
|
1503
|
+
return [] if primary.pinned?
|
|
1504
|
+
|
|
1380
1505
|
refs = Array((state.profile.reliability || {})["fallback"]).map(&:to_s)
|
|
1381
1506
|
nodes = refs.filter_map { |r| parse_model_ref(r) }.reject { |n| n[:model].to_s.empty? }
|
|
1382
1507
|
nodes.concat(Array(primary.fallbacks).map { |f| { model: f[:model], provider: f[:provider] } })
|
|
@@ -1393,6 +1518,19 @@ module Insika
|
|
|
1393
1518
|
: seen.keys.any? { |known| known.split("/").last == ref }
|
|
1394
1519
|
next if duplicate
|
|
1395
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
|
+
|
|
1396
1534
|
seen[ref] = true
|
|
1397
1535
|
Insika::ModelSelection.new(model: node[:model], provider: node[:provider],
|
|
1398
1536
|
source: :fallback, params: primary.params, fallbacks: [])
|
|
@@ -1411,6 +1549,11 @@ module Insika
|
|
|
1411
1549
|
# for data/HTTP tools. The engine transports media, never meaning: no
|
|
1412
1550
|
# speech/vision logic beyond the call itself.
|
|
1413
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
|
|
1414
1557
|
# a consumer that pre-transcribed voice text labels it `source: voice`;
|
|
1415
1558
|
# the marker rides the turn even when there are no audio PARTS left.
|
|
1416
1559
|
state.message_source = :voice if rebuild_command(task).payload["source"].to_s == "voice"
|
|
@@ -1468,7 +1611,7 @@ module Insika
|
|
|
1468
1611
|
transcriber = @media || Insika::Media.default_transcriber(
|
|
1469
1612
|
stt_model: Insika::EnvSchema.read("INSIKA_STT_MODEL"),
|
|
1470
1613
|
stt_language: Insika::EnvSchema.read("INSIKA_STT_LANGUAGE"),
|
|
1471
|
-
stt_prompt: resolved_stt_prompt(state.profile)
|
|
1614
|
+
stt_prompt: resolved_stt_prompt(state.profile), context: @llm
|
|
1472
1615
|
)
|
|
1473
1616
|
transcriber.call(url)
|
|
1474
1617
|
end
|
|
@@ -1552,8 +1695,9 @@ module Insika
|
|
|
1552
1695
|
# "routing", same turn number as the answer ask).
|
|
1553
1696
|
def route_ask(selection, prompt, message, task, st)
|
|
1554
1697
|
require "ruby_llm"
|
|
1555
|
-
chat = (
|
|
1556
|
-
|
|
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?)
|
|
1557
1701
|
chat.with_instructions(prompt) if chat.respond_to?(:with_instructions)
|
|
1558
1702
|
response = chat.ask(message.to_s)
|
|
1559
1703
|
record_model_visible(task, st, chat, part: "routing")
|
|
@@ -1604,7 +1748,7 @@ module Insika
|
|
|
1604
1748
|
"routed delegate '#{agent_id}' at depth #{depth} exceeds cap #{cap}"
|
|
1605
1749
|
end
|
|
1606
1750
|
|
|
1607
|
-
result = spawn_and_await_child(child, state
|
|
1751
|
+
result = spawn_and_await_child(child, ask_text(state), depth, state)
|
|
1608
1752
|
if result[:error]
|
|
1609
1753
|
raise Insika::RoutingError, "routed delegate '#{agent_id}' failed: #{result[:error]}"
|
|
1610
1754
|
end
|
|
@@ -1618,12 +1762,18 @@ module Insika
|
|
|
1618
1762
|
def merge_usage(main, extra)
|
|
1619
1763
|
return main || extra if main.nil? || extra.nil?
|
|
1620
1764
|
|
|
1621
|
-
Insika::Routing::TOKEN_FIELDS.each_with_object(main
|
|
1765
|
+
merged = Insika::Routing::TOKEN_FIELDS.each_with_object(extra.merge(main)) do |k, acc|
|
|
1622
1766
|
next if extra[k].nil?
|
|
1623
1767
|
next unless main.key?(k) || extra[k].to_i.positive?
|
|
1624
1768
|
|
|
1625
1769
|
acc[k] = main[k].to_i + extra[k].to_i
|
|
1626
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
|
|
1627
1777
|
end
|
|
1628
1778
|
|
|
1629
1779
|
# "provider/model" for any selection duck (ModelSelection | { model:, provider: }).
|
|
@@ -1655,7 +1805,7 @@ module Insika
|
|
|
1655
1805
|
# state (the seed history is identical), baseline reset -> the transcript
|
|
1656
1806
|
# recorded from than point is the attempt that spoke.
|
|
1657
1807
|
def build_attempt_chat(state, selection)
|
|
1658
|
-
chat = build_chat(selection, state.model_selection)
|
|
1808
|
+
chat = build_chat(selection, state.model_selection, state: state)
|
|
1659
1809
|
@chat_builder.assemble(chat, state, emit: ->(type, data) { emit(type, data, task: state.task) })
|
|
1660
1810
|
state.chat_baseline = Array(chat.messages).size if chat.respond_to?(:messages)
|
|
1661
1811
|
chat
|
|
@@ -1663,6 +1813,7 @@ module Insika
|
|
|
1663
1813
|
|
|
1664
1814
|
def new_turn_output(task, state, filter)
|
|
1665
1815
|
TurnOutput.new(filter: filter, emit: ->(type, data) { emit(type, data, task: task) },
|
|
1816
|
+
halt_text: state.approval_continuation&.fetch("halt_text", ""),
|
|
1666
1817
|
public_intermediate: state.profile.stream_public?(:intermediate))
|
|
1667
1818
|
end
|
|
1668
1819
|
|
|
@@ -1671,6 +1822,7 @@ module Insika
|
|
|
1671
1822
|
# anything is appended after it — the gem's callbacks are additive and run
|
|
1672
1823
|
# in registration order.
|
|
1673
1824
|
def wire_chat_output(task, state, output)
|
|
1825
|
+
state.turn_output = output
|
|
1674
1826
|
chat = state.chat
|
|
1675
1827
|
chat.after_message { |message| output.message_ended(message) } if chat.respond_to?(:after_message)
|
|
1676
1828
|
state.steer_injector = install_steer_injector(task, state)
|
|
@@ -1694,16 +1846,10 @@ module Insika
|
|
|
1694
1846
|
return if halted?(result[:response])
|
|
1695
1847
|
return if injector.absorb_pending!.zero?
|
|
1696
1848
|
|
|
1697
|
-
# TWO provider round trips now. Bank the first one's tokens BEFORE the response
|
|
1698
|
-
# is replaced — stage 6's merge only sees whatever `result[:response]` ends up
|
|
1699
|
-
# being, so without this the extra round silently erases the first round's cost
|
|
1700
|
-
# from the terminal usage and from the EdgeLimiter's budget.
|
|
1701
|
-
state.usage = merge_usage(with_model_source(usage_of(result[:response]), state.model_selection),
|
|
1702
|
-
state.usage)
|
|
1703
1849
|
# COMPLETE, not `ask(nil)`: the messages are already in the history, and an
|
|
1704
1850
|
# ask with no text appends an EMPTY user message after them — which is both a
|
|
1705
1851
|
# duplicate turn-opener and something providers refuse outright.
|
|
1706
|
-
extra = state.chat
|
|
1852
|
+
extra = complete_chat(state, state.chat, &turn_chunk_handler(task, state, result[:output], timing))
|
|
1707
1853
|
result[:response] = extra
|
|
1708
1854
|
result[:asked] = extra
|
|
1709
1855
|
end
|
|
@@ -1721,12 +1867,150 @@ module Insika
|
|
|
1721
1867
|
# An image with no caption asks with NIL, not "": an empty text part is a
|
|
1722
1868
|
# thing some providers refuse, and nil is how RubyLLM says "attachments
|
|
1723
1869
|
# only".
|
|
1724
|
-
if state.
|
|
1725
|
-
|
|
1726
|
-
|
|
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)
|
|
1727
1880
|
else
|
|
1728
|
-
chat.
|
|
1881
|
+
chat.ask_later(ask_text(state))
|
|
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)
|
|
1729
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)
|
|
1730
2014
|
end
|
|
1731
2015
|
|
|
1732
2016
|
# The per-round chunk sink. Built FRESH per round: `ttft_sent` is that round's
|
|
@@ -1788,7 +2072,7 @@ module Insika
|
|
|
1788
2072
|
# stage, because the `:agent` after-hook runs after the message boundary and is
|
|
1789
2073
|
# allowed to have the last word.
|
|
1790
2074
|
def turn_answer(response, asked, output, filter)
|
|
1791
|
-
# HALTED BY A TOOL RESULT (`halt_when`):
|
|
2075
|
+
# HALTED BY A TOOL RESULT (`halt_when`): complete_chat returns the marker
|
|
1792
2076
|
# instead of a Message, and its `content` is the tool PAYLOAD — publishing it
|
|
1793
2077
|
# would ship the envelope to the customer as the answer. The turn is worth
|
|
1794
2078
|
# exactly the lead-in of the message that called the tool (the model's "vou te
|
|
@@ -1821,7 +2105,7 @@ module Insika
|
|
|
1821
2105
|
Insika::ToolDefinition.halt_say_of(content_of(response)).to_s
|
|
1822
2106
|
end
|
|
1823
2107
|
|
|
1824
|
-
def halted?(response) = response.is_a?(
|
|
2108
|
+
def halted?(response) = response.is_a?(Insika::ToolDefinition::Halt)
|
|
1825
2109
|
|
|
1826
2110
|
def content_of(response) = response.respond_to?(:content) ? response.content : response.to_s
|
|
1827
2111
|
|
|
@@ -1866,31 +2150,33 @@ module Insika
|
|
|
1866
2150
|
return usage if usage.nil? || selection.nil?
|
|
1867
2151
|
|
|
1868
2152
|
usage[:model_source] = selection.source
|
|
1869
|
-
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
|
|
1870
2159
|
usage
|
|
1871
2160
|
end
|
|
1872
2161
|
|
|
1873
|
-
#
|
|
1874
|
-
#
|
|
1875
|
-
#
|
|
1876
|
-
# OpenAI Responses usage (input/output/total) + model, consumed by
|
|
1877
|
-
# /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.
|
|
1878
2165
|
def usage_of(response)
|
|
1879
|
-
return nil unless response.respond_to?(:
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
# R3: prompt-cache WRITE tokens (Anthropic cache_creation_input_tokens),
|
|
1888
|
-
# billed at ~1.25x. Reported so the first (write) turn vs later (read) turns
|
|
1889
|
-
# are distinguishable in telemetry/usage.
|
|
1890
|
-
if response.respond_to?(:cache_creation_tokens) && response.cache_creation_tokens
|
|
1891
|
-
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
|
|
1892
2174
|
end
|
|
1893
|
-
|
|
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
|
|
1894
2180
|
usage
|
|
1895
2181
|
end
|
|
1896
2182
|
|
|
@@ -1906,6 +2192,17 @@ module Insika
|
|
|
1906
2192
|
payload["message"] || payload["input"]
|
|
1907
2193
|
end
|
|
1908
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
|
+
|
|
1909
2206
|
def command_history(task)
|
|
1910
2207
|
rebuild_command(task).payload["history"]
|
|
1911
2208
|
end
|
|
@@ -1913,19 +2210,27 @@ module Insika
|
|
|
1913
2210
|
def build_context_request(task, profile, state, resume_from)
|
|
1914
2211
|
session = task.session_id ? @session_store.find(task.session_id) : nil
|
|
1915
2212
|
state.session = session # create_chat reads it for the per-chat model pin
|
|
1916
|
-
hist = command_history(task)
|
|
2213
|
+
hist = @shared_turns.dig(task.id, :history) || command_history(task)
|
|
1917
2214
|
# `vars` reconciles the seam (the Request/Session provider already
|
|
1918
2215
|
# called request.vars): session metadata + the explicit `history` in the
|
|
1919
2216
|
# convention the Session provider consumes (vars["history"]).
|
|
1920
2217
|
vars = (session&.vars || {}).dup
|
|
1921
2218
|
vars["history"] = hist if hist
|
|
2219
|
+
vars["shared_memory"] = @shared_turns[task.id][:memory] if @shared_turns[task.id]
|
|
1922
2220
|
# The single type is Insika::ContextRequest (Data); the explicit `history`
|
|
1923
2221
|
# travels in vars["history"] (Session provider convention), not in a field
|
|
1924
2222
|
# of its own. `memory_scope` is the WS8 customer cell (nil = the providers
|
|
1925
2223
|
# fall back to tenant || session, today's behavior).
|
|
2224
|
+
turn = state.turn
|
|
1926
2225
|
ContextRequest.new(profile: profile, message: state.message, session: session,
|
|
1927
2226
|
checkpoint: resume_from, tenant: command_tenant(task), vars: vars,
|
|
1928
|
-
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
|
+
})
|
|
1929
2234
|
end
|
|
1930
2235
|
|
|
1931
2236
|
# task_started payload. Carries the EXPLICIT command tenant so
|
|
@@ -1979,16 +2284,22 @@ module Insika
|
|
|
1979
2284
|
# distillation engine resolves each session's pack through it) on the
|
|
1980
2285
|
# session ONCE (idempotent). A session that does not exist yet (no
|
|
1981
2286
|
# session_id on the turn) is skipped; a look-up failure never breaks the
|
|
1982
|
-
# turn.
|
|
2287
|
+
# turn. The optional labels (SessionStore::CUSTOMER_LABELS: name, phone)
|
|
2288
|
+
# are NOT stamped once: they follow the latest value the caller sends.
|
|
1983
2289
|
def stamp_customer_session(task, profile)
|
|
1984
2290
|
customer = command_customer(task)
|
|
1985
|
-
|
|
2291
|
+
labels = command_customer_labels(task)
|
|
2292
|
+
return if (customer.nil? && labels.empty?) || task.session_id.nil?
|
|
1986
2293
|
|
|
1987
2294
|
session = @session_store&.find(task.session_id)
|
|
1988
|
-
return if session.nil?
|
|
2295
|
+
return if session.nil?
|
|
1989
2296
|
|
|
1990
|
-
|
|
1991
|
-
|
|
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?
|
|
1992
2303
|
rescue Insika::NotFoundError, ArgumentError
|
|
1993
2304
|
nil
|
|
1994
2305
|
end
|
|
@@ -2001,17 +2312,31 @@ module Insika
|
|
|
2001
2312
|
Coercion.presence(rebuild_command(task).payload["customer"])
|
|
2002
2313
|
end
|
|
2003
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
|
+
|
|
2004
2326
|
# Turn context: the ids the data-tools resolve via
|
|
2005
2327
|
# {{ctx.*}} to emit X-Chat-Id/X-Store-Id/X-Agent-Id to /api/internal/*. They
|
|
2006
|
-
# come from the TURN, never from the model args (R2). chat_id
|
|
2007
|
-
#
|
|
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;
|
|
2008
2330
|
# tenant = the Command tenant (memory) OR chat_id (drop-in default); agent_id =
|
|
2009
2331
|
# profile; store_id = the profile metadata (stable per store, from the pack).
|
|
2010
2332
|
# Absent fields -> nil (the data-tool emits an empty header; in the pilot the
|
|
2011
2333
|
# profile carries store_id). Generic: nothing here mentions a consumer.
|
|
2012
2334
|
def build_turn_context(task, profile, state)
|
|
2335
|
+
tenant = command_tenant(task)
|
|
2013
2336
|
{
|
|
2014
|
-
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,
|
|
2015
2340
|
agent_id: profile.id,
|
|
2016
2341
|
# the DATA-TOOL header tenant stays the merchant (or the chat), even when
|
|
2017
2342
|
# the memory scope carries a customer — the backend identifies the store,
|
|
@@ -2326,6 +2651,7 @@ module Insika
|
|
|
2326
2651
|
|
|
2327
2652
|
@task_store.transition(task.id, to: :paused)
|
|
2328
2653
|
emit(:task_paused, { task_id: task.id }, task: task)
|
|
2654
|
+
flush_llm_diagnostics(task) # the wait may last hours
|
|
2329
2655
|
actor.await(reason: :paused) # blocks until :resume (or raises on :cancel/:timeout)
|
|
2330
2656
|
@task_store.transition(task.id, to: :running)
|
|
2331
2657
|
emit(:task_resumed, { task_id: task.id }, task: task)
|
|
@@ -2336,13 +2662,11 @@ module Insika
|
|
|
2336
2662
|
# (does not re-save — the CheckpointStore's monotonicity would raise). It emits
|
|
2337
2663
|
# NO event (:checkpoint_created is stage 8's only) and touches no side-effects.
|
|
2338
2664
|
def save_initial_checkpoint(task, profile, state)
|
|
2339
|
-
return unless @checkpoint_store.find(task.id, turn: state.turn).nil?
|
|
2340
|
-
|
|
2341
2665
|
@checkpoint_store.save(Insika::Checkpoint.new(
|
|
2342
2666
|
task_id: task.id, turn: state.turn, session_id: task.session_id,
|
|
2343
2667
|
agent_id: profile.id, messages: flatten_history(state.context.history),
|
|
2344
2668
|
completed_side_effects: [], created_at: Time.now.utc.iso8601
|
|
2345
|
-
))
|
|
2669
|
+
), if_absent: true)
|
|
2346
2670
|
end
|
|
2347
2671
|
|
|
2348
2672
|
# GRACEFUL halt: a Middleware short-circuited with a safe reply.
|
|
@@ -2374,8 +2698,9 @@ module Insika
|
|
|
2374
2698
|
persist_turn(task, profile, state, content, reply_origin: MessageOrigin::ENGINE,
|
|
2375
2699
|
session: state.guardrail_block&.[](:source) != "edge", timing: timing)
|
|
2376
2700
|
data = { task_id: task.id, content: content, usage: state.usage }
|
|
2377
|
-
|
|
2378
|
-
|
|
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)
|
|
2379
2704
|
emit(:task_completed, data, task: task)
|
|
2380
2705
|
end
|
|
2381
2706
|
|
|
@@ -2383,9 +2708,12 @@ module Insika
|
|
|
2383
2708
|
# The record gains `timing` once, when the turn completes; a store failure
|
|
2384
2709
|
# here is swallowed — the turn is already committed and the event already
|
|
2385
2710
|
# carries the number.
|
|
2386
|
-
|
|
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)
|
|
2387
2714
|
return unless timing
|
|
2388
2715
|
|
|
2716
|
+
record_turn_metrics(task, timing, profile, usage)
|
|
2389
2717
|
hash = timing.to_h
|
|
2390
2718
|
return if hash.empty?
|
|
2391
2719
|
|
|
@@ -2394,6 +2722,28 @@ module Insika
|
|
|
2394
2722
|
nil
|
|
2395
2723
|
end
|
|
2396
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
|
+
|
|
2397
2747
|
# Emits one :guardrail_flagged per flag the OutputValidator appended in
|
|
2398
2748
|
# after_task (audit only — the turn already completed). Reads a plain Array off
|
|
2399
2749
|
# the state, keeping the Executor decoupled from Safety. an
|
|
@@ -2426,9 +2776,27 @@ module Insika
|
|
|
2426
2776
|
# So a long session legitimately has a Checkpoint SHORTER than the Session:
|
|
2427
2777
|
# that is not drift to reconcile — it is the point. Do NOT "fix" the checkpoint
|
|
2428
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
|
+
|
|
2429
2793
|
def persist_turn(task, profile, state, content, session: true, reply_origin: nil, timing: nil)
|
|
2430
2794
|
new_messages = turn_transcript(state, content, origin: command_origin(task), reply_origin: reply_origin)
|
|
2431
|
-
|
|
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
|
|
2432
2800
|
|
|
2433
2801
|
@checkpoint_store.save(Insika::Checkpoint.new(
|
|
2434
2802
|
task_id: task.id, turn: state.turn + 1, session_id: task.session_id,
|
|
@@ -2439,12 +2807,26 @@ module Insika
|
|
|
2439
2807
|
# session only when the turn is from a persisted session; one-shot/history
|
|
2440
2808
|
# do not persist TO THE SESSION (but always checkpoint). `session: false` is
|
|
2441
2809
|
# the edge-blocked halt (see complete_with_halt).
|
|
2442
|
-
|
|
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
|
|
2443
2816
|
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
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
|
|
2827
|
+
|
|
2828
|
+
# closes the Execution and moves to :completed in one write.
|
|
2829
|
+
@task_store.complete_execution(task.id, outcome: :completed)
|
|
2448
2830
|
# prune is best-effort cleanup: a failure here must NOT re-fail an
|
|
2449
2831
|
# already-committed turn (the task is already :completed and durable).
|
|
2450
2832
|
# Swallow.
|
|
@@ -2470,13 +2852,13 @@ module Insika
|
|
|
2470
2852
|
# path — the user already has the answer above. Same terminal hook,
|
|
2471
2853
|
# next door to the other two, for the same reason: it fires for a fresh
|
|
2472
2854
|
# turn and a recovered one.
|
|
2473
|
-
finalize_knowledge_extraction(task, profile, new_messages)
|
|
2855
|
+
finalize_knowledge_extraction(task, profile, new_messages, state)
|
|
2474
2856
|
|
|
2475
2857
|
# in-session compaction (RFC-0044): when the uncompacted
|
|
2476
2858
|
# transcript crossed the threshold this turn, summarize the old prefix
|
|
2477
2859
|
# with the cheap model and move the boundary — off the critical path,
|
|
2478
2860
|
# same terminal hook, same best-effort discipline as the other three.
|
|
2479
|
-
finalize_compaction(task, profile)
|
|
2861
|
+
finalize_compaction(task, profile, state.chat)
|
|
2480
2862
|
end
|
|
2481
2863
|
|
|
2482
2864
|
# Records the answer in the outbox and dispatches it. The discriminator is the
|
|
@@ -2546,22 +2928,27 @@ module Insika
|
|
|
2546
2928
|
# boot sweep), a child of the turn supervisor when serving (survives the
|
|
2547
2929
|
# request's own disconnect). Best-effort: any failure is swallowed here,
|
|
2548
2930
|
# never re-fails an already-committed turn.
|
|
2549
|
-
def finalize_knowledge_extraction(task, profile, new_messages)
|
|
2931
|
+
def finalize_knowledge_extraction(task, profile, new_messages, state = nil)
|
|
2550
2932
|
return unless @knowledge_store
|
|
2933
|
+
return if simulated_session?(task.session_id)
|
|
2551
2934
|
|
|
2552
2935
|
config = Coercion.deep_stringify(profile.knowledge)
|
|
2553
2936
|
return unless config && Coercion.truthy?(config["extract"])
|
|
2554
2937
|
return if knowledge_transcript(new_messages).length < KNOWLEDGE_MIN_CHARS
|
|
2555
2938
|
|
|
2556
|
-
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"))
|
|
2557
2941
|
return unless extractor
|
|
2558
2942
|
|
|
2559
2943
|
# Same resolved model as the extractor — a deployment names one
|
|
2560
2944
|
# knowledge model, not two. nil consolidator (no model resolvable) is
|
|
2561
2945
|
# still meaningful: write_concept's conservative default.
|
|
2562
|
-
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"))
|
|
2563
2948
|
|
|
2564
|
-
|
|
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) }
|
|
2565
2952
|
return run.call unless @supervised
|
|
2566
2953
|
|
|
2567
2954
|
turn_parent.async do |t|
|
|
@@ -2570,10 +2957,17 @@ module Insika
|
|
|
2570
2957
|
end
|
|
2571
2958
|
end
|
|
2572
2959
|
|
|
2573
|
-
def run_knowledge_extraction(task, profile, config, new_messages, extractor, consolidator)
|
|
2574
|
-
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)
|
|
2575
2962
|
result = extractor.extract(prompt: prompt)
|
|
2576
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
|
|
2577
2971
|
outcome = Knowledge.write_concept(
|
|
2578
2972
|
store: @knowledge_store, agent_id: profile.id, concept: concept, session_id: task.session_id,
|
|
2579
2973
|
tenant: task_tenant(task), consolidator: consolidator
|
|
@@ -2596,17 +2990,29 @@ module Insika
|
|
|
2596
2990
|
end
|
|
2597
2991
|
end
|
|
2598
2992
|
|
|
2599
|
-
|
|
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)
|
|
2600
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" : ""
|
|
2601
3004
|
<<~PROMPT
|
|
2602
3005
|
#{base.rstrip}
|
|
2603
|
-
|
|
3006
|
+
#{agent}
|
|
2604
3007
|
## The conversation
|
|
2605
3008
|
|
|
2606
3009
|
#{knowledge_transcript(new_messages)}
|
|
2607
3010
|
PROMPT
|
|
2608
3011
|
end
|
|
2609
3012
|
|
|
3013
|
+
# Synthetic traffic must not teach the production agent (SessionStore.synthetic?).
|
|
3014
|
+
def simulated_session?(session_id) = Insika::SessionStore.synthetic?(session_id)
|
|
3015
|
+
|
|
2610
3016
|
# Only what people said, PII-redacted — a `role: tool` message is
|
|
2611
3017
|
# third-party text and never a source of a learned concept.
|
|
2612
3018
|
def knowledge_transcript(new_messages) = Insika::SpokenTranscript.render(new_messages)
|
|
@@ -2632,7 +3038,7 @@ module Insika
|
|
|
2632
3038
|
# finalize_knowledge_extraction: inline when non-supervised, a child of the
|
|
2633
3039
|
# turn supervisor when serving. Best-effort: any failure leaves the session
|
|
2634
3040
|
# record untouched and the next turn re-plans.
|
|
2635
|
-
def finalize_compaction(task, profile)
|
|
3041
|
+
def finalize_compaction(task, profile, chat = nil)
|
|
2636
3042
|
return unless task.session_id && @settings_store
|
|
2637
3043
|
|
|
2638
3044
|
config = Coercion.deep_stringify(@settings_store.get["compaction"])
|
|
@@ -2642,16 +3048,29 @@ module Insika
|
|
|
2642
3048
|
return unless session
|
|
2643
3049
|
|
|
2644
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"]
|
|
2645
3058
|
plan = Insika::Compaction.plan(messages: session.messages, state: state, config: config)
|
|
2646
3059
|
return unless plan
|
|
2647
3060
|
|
|
2648
3061
|
# compaction.model -> platform utility_model -> inert (never a guess);
|
|
2649
3062
|
# `insika doctor` warns on enabled-with-no-model. @llm rides along so a
|
|
2650
3063
|
# deployment (or spec) that injects its LLM seam covers this call too.
|
|
2651
|
-
|
|
2652
|
-
|
|
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
|
|
2653
3071
|
|
|
2654
|
-
|
|
3072
|
+
run = lambda { run_compaction(task, profile, session, state, plan, config, summarizer) }
|
|
3073
|
+
end
|
|
2655
3074
|
return run.call unless @supervised
|
|
2656
3075
|
|
|
2657
3076
|
turn_parent.async do |t|
|
|
@@ -2660,6 +3079,27 @@ module Insika
|
|
|
2660
3079
|
end
|
|
2661
3080
|
end
|
|
2662
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
|
+
|
|
2663
3103
|
def run_compaction(task, profile, session, state, plan, config, summarizer)
|
|
2664
3104
|
prompt = Insika::Compaction.prompt(messages: session.messages, plan: plan,
|
|
2665
3105
|
previous: state && state["summary"],
|
|
@@ -2719,28 +3159,39 @@ module Insika
|
|
|
2719
3159
|
def turn_transcript(state, content, origin: nil, reply_origin: nil)
|
|
2720
3160
|
recorded = recorded_turn_messages(state)
|
|
2721
3161
|
if recorded.empty?
|
|
2722
|
-
return [MessageOrigin.stamp({ "role" => "user", "content" => state
|
|
3162
|
+
return [stamp_guardrail_flags(MessageOrigin.stamp({ "role" => "user", "content" => ask_text(state) }, origin), state),
|
|
2723
3163
|
MessageOrigin.stamp({ "role" => "assistant", "content" => content.to_s }, reply_origin)]
|
|
2724
3164
|
end
|
|
2725
3165
|
|
|
2726
|
-
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"
|
|
2727
3167
|
recorded[-1] = recorded[-1].merge("content" => content.to_s) if recorded.last["role"] == "assistant"
|
|
2728
3168
|
recorded
|
|
2729
3169
|
end
|
|
2730
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
|
+
|
|
2731
3182
|
# Slices the chat's messages added DURING this turn and serializes them.
|
|
2732
3183
|
# [] when there is no recorded transcript (baseline nil / no #messages).
|
|
2733
|
-
def recorded_turn_messages(state)
|
|
2734
|
-
chat = state.chat
|
|
3184
|
+
def recorded_turn_messages(state, chat: state.chat)
|
|
2735
3185
|
baseline = state.chat_baseline
|
|
2736
3186
|
return [] unless chat && baseline && chat.respond_to?(:messages)
|
|
2737
3187
|
|
|
2738
|
-
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))) }
|
|
2739
3189
|
end
|
|
2740
3190
|
|
|
2741
3191
|
# A RubyLLM::Message (duck-typed) -> string-keyed Hash. Assistant carries
|
|
2742
3192
|
# "tool_calls" only when present; tool carries "tool_call_id" + a clipped content.
|
|
2743
|
-
|
|
3193
|
+
# Do not persist raw_content: native replay would bypass redaction and clipping.
|
|
3194
|
+
def serialize_chat_message(msg, clip: true)
|
|
2744
3195
|
role = msg_field(msg, :role).to_s
|
|
2745
3196
|
content = msg_field(msg, :content).to_s
|
|
2746
3197
|
case role
|
|
@@ -2751,7 +3202,7 @@ module Insika
|
|
|
2751
3202
|
h
|
|
2752
3203
|
when "tool"
|
|
2753
3204
|
{ "role" => "tool", "tool_call_id" => msg_field(msg, :tool_call_id).to_s,
|
|
2754
|
-
"content" => clip_tool_content(content) }
|
|
3205
|
+
"content" => clip ? clip_tool_content(content) : content }
|
|
2755
3206
|
else
|
|
2756
3207
|
{ "role" => role, "content" => content }
|
|
2757
3208
|
end
|
|
@@ -2800,6 +3251,7 @@ module Insika
|
|
|
2800
3251
|
require_relative "tools/load_knowledge"
|
|
2801
3252
|
require_relative "tools/tool_search"
|
|
2802
3253
|
require_relative "tools/remember"
|
|
3254
|
+
require_relative "tools/confirm_pending"
|
|
2803
3255
|
require_relative "tools/subagent"
|
|
2804
3256
|
require_relative "tools/subagents"
|
|
2805
3257
|
require_relative "tools/stuck_signal"
|
|
@@ -2814,16 +3266,17 @@ module Insika
|
|
|
2814
3266
|
# enforced, fallback chain resolved. Kept on the state for telemetry (usage).
|
|
2815
3267
|
selection = @model_resolver.resolve(profile: profile, session: state.session)
|
|
2816
3268
|
state.model_selection = selection
|
|
2817
|
-
build_chat(selection, selection)
|
|
3269
|
+
build_chat(selection, selection, state: state)
|
|
2818
3270
|
end
|
|
2819
3271
|
|
|
2820
3272
|
# The gem boundary: one chat for a model selection (the resolved primary or
|
|
2821
3273
|
# a WS3 fallback node). The primary's generation params apply to the whole
|
|
2822
3274
|
# chain (params_source: ModelSelection#apply_params).
|
|
2823
|
-
def build_chat(selection, params_source)
|
|
3275
|
+
def build_chat(selection, params_source, state: nil)
|
|
2824
3276
|
model = selection.respond_to?(:model) ? selection.model : selection[:model]
|
|
2825
3277
|
provider = selection.respond_to?(:provider) ? selection.provider : selection[:provider]
|
|
2826
|
-
|
|
3278
|
+
@llm_refresh&.call
|
|
3279
|
+
chat = llm_operation_context(state, model).chat(
|
|
2827
3280
|
model: model,
|
|
2828
3281
|
provider: provider,
|
|
2829
3282
|
assume_model_exists: !provider.nil?
|
|
@@ -2832,6 +3285,62 @@ module Insika
|
|
|
2832
3285
|
chat
|
|
2833
3286
|
end
|
|
2834
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
|
+
|
|
2835
3344
|
# Single emitter: an Event with meta and a monotonic seq per task. @seqs is not
|
|
2836
3345
|
# cleared at the end of the task — the resume (new Execution) continues the
|
|
2837
3346
|
# numbering (reliable replay). A task WITH a tenant (WS1) tags every event it
|
|
@@ -2839,10 +3348,28 @@ module Insika
|
|
|
2839
3348
|
# event without a task has no tenant and never matches a tenant stream);
|
|
2840
3349
|
# absent tenant -> the meta is byte-identical to before.
|
|
2841
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
|
|
2842
3355
|
meta = { task_id: task.id, session_id: task.session_id,
|
|
2843
|
-
seq: (@seqs[task.id] += 1), at: Time.now.utc.iso8601 }
|
|
3356
|
+
seq: (@seqs[task.id] += 1), at: Time.now.utc.iso8601(6) }
|
|
2844
3357
|
tenant = task_tenant(task)
|
|
2845
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
|
|
2846
3373
|
@event_stream.emit(Insika::Event.new(type: type, data: data, meta: meta))
|
|
2847
3374
|
end
|
|
2848
3375
|
|