insika 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Tools
|
|
7
|
+
# Level 2 of TOOLS progressive disclosure (analog of LoadSkill):
|
|
8
|
+
# searches the deferred catalog and PROMOTES the relevant ones into the live chat via
|
|
9
|
+
# chat.with_tools (verified to propagate on the next round of the same `ask`
|
|
10
|
+
# in ruby_llm 1.16). `require "ruby_llm"` stays in THIS file (it inherits from
|
|
11
|
+
# RubyLLM::Tool) — it does not enter lib/insika.rb; the Executor loads it lazily in
|
|
12
|
+
# configure_chat (like LoadSkill).
|
|
13
|
+
class ToolSearch < RubyLLM::Tool
|
|
14
|
+
description "Searches and enables additional tools by describing the need"
|
|
15
|
+
param :query, desc: "What you need to do (e.g.: 'send email', 'generate invoice')"
|
|
16
|
+
|
|
17
|
+
# RubyLLM::Tool#name derives from self.class.name — for a nested class
|
|
18
|
+
# (Insika::Tools::ToolSearch) it produces "insika--tools--tool_search", not
|
|
19
|
+
# "tool_search". Explicit override: the name the model calls must match
|
|
20
|
+
# the catalog/docs/tests.
|
|
21
|
+
def name = "tool_search"
|
|
22
|
+
|
|
23
|
+
def initialize(catalog, deferred_allowed, chat, tool_registry:, event_stream:,
|
|
24
|
+
checkpoint_store:, state:)
|
|
25
|
+
@catalog = catalog
|
|
26
|
+
@deferred_allowed = Array(deferred_allowed).map(&:to_s)
|
|
27
|
+
@chat = chat
|
|
28
|
+
@tool_registry = tool_registry
|
|
29
|
+
@event_stream = event_stream
|
|
30
|
+
@checkpoint_store = checkpoint_store
|
|
31
|
+
@state = state
|
|
32
|
+
@promoted = [] # names already promoted IN THIS chat — idempotency
|
|
33
|
+
super()
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def execute(query:)
|
|
37
|
+
matches = @catalog.search(query, within: @deferred_allowed)
|
|
38
|
+
emit_tool_search(query, matches.map(&:name))
|
|
39
|
+
if matches.empty?
|
|
40
|
+
return { matched: [], message: "no tool found for '#{query}'" }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
new_matches = matches.reject { |m| @promoted.include?(m.name) }
|
|
44
|
+
promote(new_matches) unless new_matches.empty?
|
|
45
|
+
|
|
46
|
+
{ matched: matches.map { |m| describe(m) } }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Instantiates (via tool_registry), wraps in the SAME ToolEnvelope as the eager
|
|
52
|
+
# ones (profile timeout + state's skip_side_effects) and promotes via
|
|
53
|
+
# chat.with_tools. A NotFoundError (misaligned catalog) drops only
|
|
54
|
+
# that match — the search does not break.
|
|
55
|
+
def promote(entries)
|
|
56
|
+
timeout = @state.profile.limits[:tool_timeout] || 60
|
|
57
|
+
wrapped = entries.filter_map do |entry|
|
|
58
|
+
tool = @tool_registry.resolve(entry.name)
|
|
59
|
+
@promoted << entry.name
|
|
60
|
+
ToolEnvelope.new(tool, state: @state, checkpoint_store: @checkpoint_store,
|
|
61
|
+
tool_registry: @tool_registry, timeout: timeout,
|
|
62
|
+
skip_side_effects: Array(@state.skip_side_effects))
|
|
63
|
+
rescue Insika::NotFoundError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
@chat.with_tools(*wrapped) unless wrapped.empty?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Mirrors :skill_activated, but emitted by the tool itself (it has event_stream/
|
|
70
|
+
# state in the constructor). Without a monotonic `seq` (private to the Executor) — a
|
|
71
|
+
# documented gap, not a blocker.
|
|
72
|
+
def emit_tool_search(query, matched_names)
|
|
73
|
+
@event_stream.emit(Insika::Event.new(
|
|
74
|
+
type: :tool_search,
|
|
75
|
+
data: { query: query, matched: matched_names },
|
|
76
|
+
meta: { task_id: @state.task.id, session_id: @state.task.session_id }
|
|
77
|
+
))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def describe(entry)
|
|
81
|
+
tool = @tool_registry.resolve(entry.name)
|
|
82
|
+
{
|
|
83
|
+
name: entry.name,
|
|
84
|
+
description: entry.description,
|
|
85
|
+
parameters: tool.parameters.transform_values do |p|
|
|
86
|
+
{ type: p.type, description: p.description, required: p.required }
|
|
87
|
+
end
|
|
88
|
+
}
|
|
89
|
+
rescue Insika::NotFoundError
|
|
90
|
+
{ name: entry.name, description: entry.description, parameters: {} }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# WHAT REACHES THE CUSTOMER — the turn's publishing rule.
|
|
5
|
+
#
|
|
6
|
+
# A turn is not one assistant message. Between the user's message and the answer
|
|
7
|
+
# the model may narrate the loop ("deixa eu buscar isso pra você"), apologise for
|
|
8
|
+
# a tool that failed, or — when it has no tool to call — reason in prose. All of
|
|
9
|
+
# it arrives as ordinary `content` chunks, indistinguishable at the token level
|
|
10
|
+
# from the answer, so streaming every chunk straight to `:content` published all
|
|
11
|
+
# of it. Running a real store's prompt proved what that costs: 132 deltas of an
|
|
12
|
+
# English monologue ("Let me check the tools I actually have… Actually, let me
|
|
13
|
+
# reconsider.") went out as `response.output_text.delta` — the bytes a WhatsApp
|
|
14
|
+
# customer would have read.
|
|
15
|
+
#
|
|
16
|
+
# The rule, enforced here: **`:content` carries the ANSWER — the text of the
|
|
17
|
+
# assistant message that ENDS the turn.** Everything else rides `:intermediate`,
|
|
18
|
+
# which the Studio and the trace render (that is the operator's window into what
|
|
19
|
+
# the model narrated) and which `/v1/responses` deliberately does not translate,
|
|
20
|
+
# exactly like `:thinking`.
|
|
21
|
+
#
|
|
22
|
+
# Which message is the last one is only knowable when it ends — a message that
|
|
23
|
+
# carries tool calls is never the answer — so text is buffered per message and
|
|
24
|
+
# published at the boundary (RubyLLM's `after_message`). Two consequences, both
|
|
25
|
+
# deliberate:
|
|
26
|
+
#
|
|
27
|
+
# · The customer-visible stream is per MESSAGE, not per token. `ttft_ms` still
|
|
28
|
+
# measures the provider's first token, so's baselines stay comparable;
|
|
29
|
+
# what moved is when the customer can read it. For the WhatsApp edge this
|
|
30
|
+
# changes nothing — the dispatcher accumulated the deltas into one message
|
|
31
|
+
# anyway — and the Studio keeps its live typing off `:intermediate`.
|
|
32
|
+
# · A turn that dies mid-message publishes nothing. Half a sentence was never an
|
|
33
|
+
# answer; the fragment is still on the stream as `:intermediate` for whoever is
|
|
34
|
+
# debugging it.
|
|
35
|
+
#
|
|
36
|
+
# `halt_when` (PR #130) is the one case where narration IS the turn: the tool
|
|
37
|
+
# already answered the customer, and the model's lead-in ("vou te inscrever
|
|
38
|
+
# agora") is all the turn is worth. That text is retained and published by the
|
|
39
|
+
# Executor's halt branch — see #halt_text.
|
|
40
|
+
class TurnOutput
|
|
41
|
+
# The text of the message that ended the turn, or nil if no boundary said so.
|
|
42
|
+
# It is a CANDIDATE, not the published answer: the `:agent` after-hook runs
|
|
43
|
+
# after the message ends and may replace the whole response, so the Executor
|
|
44
|
+
# decides and publishes once, at the end of the stage.
|
|
45
|
+
attr_reader :candidate
|
|
46
|
+
|
|
47
|
+
# filter: Safety::OutputFilter | nil (nil = stream untouched).
|
|
48
|
+
# emit: ->(type, data) — the Executor's emitter, already bound to the task.
|
|
49
|
+
# public_intermediate: the agent opted this channel in (`edge_stream`), so the
|
|
50
|
+
# narration is TAGGED and `/v1/responses` gives it its own frame. Default false:
|
|
51
|
+
# an internal event stays internal unless someone said otherwise.
|
|
52
|
+
def initialize(filter:, emit:, public_intermediate: false)
|
|
53
|
+
@filter = filter
|
|
54
|
+
@emit = emit
|
|
55
|
+
@public_intermediate = public_intermediate
|
|
56
|
+
@pending = +"" # text of the message currently streaming
|
|
57
|
+
@last_intermediate = +"" # text of the last message that turned out NOT to be the answer
|
|
58
|
+
@candidate = nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# One provider chunk. Publishes the redacted slice as `:intermediate` — live,
|
|
62
|
+
# chunk by chunk, because the operator surfaces want to watch it happen — and
|
|
63
|
+
# holds it until the message boundary decides what it was.
|
|
64
|
+
def push(text)
|
|
65
|
+
slice = @filter ? @filter.push(text) : text.to_s
|
|
66
|
+
return if slice.empty?
|
|
67
|
+
|
|
68
|
+
@pending << slice
|
|
69
|
+
emit_intermediate(slice)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A message ended (RubyLLM `after_message`). Only an assistant message decides
|
|
73
|
+
# anything — a `role: tool` result is the gem's bookkeeping. A message carrying
|
|
74
|
+
# tool calls is intermediate by definition: the model asked for something, so
|
|
75
|
+
# it was not done talking.
|
|
76
|
+
def message_ended(message)
|
|
77
|
+
return unless assistant?(message)
|
|
78
|
+
|
|
79
|
+
flush
|
|
80
|
+
text = @pending
|
|
81
|
+
@pending = +""
|
|
82
|
+
tool_calls?(message) ? @last_intermediate = text : @candidate = text
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Releases the redactor's retained tail (the sliding buffer holds back a value
|
|
86
|
+
# that might still be growing into a match) into the current message.
|
|
87
|
+
def flush
|
|
88
|
+
return unless @filter
|
|
89
|
+
|
|
90
|
+
tail = @filter.flush.to_s
|
|
91
|
+
return if tail.empty?
|
|
92
|
+
|
|
93
|
+
@pending << tail
|
|
94
|
+
emit_intermediate(tail)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Emits the answer and returns it. Empty text emits no event — an empty turn is
|
|
98
|
+
# what the consumer suppresses.
|
|
99
|
+
def publish(text)
|
|
100
|
+
answer = text.to_s
|
|
101
|
+
@emit.call(:content, { delta: answer }) unless answer.empty?
|
|
102
|
+
answer
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The narration of the message that halted the turn. Normally the message
|
|
106
|
+
# boundary already moved it aside (`@last_intermediate`); a transport that does
|
|
107
|
+
# not report boundaries leaves it in `@pending`, and the lead-in is worth the
|
|
108
|
+
# same either way.
|
|
109
|
+
def halt_text = @last_intermediate.empty? ? @pending : @last_intermediate
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# `public: true` is the whole difference between an event the edge drops and one
|
|
114
|
+
# it translates. The flag travels on the EVENT because `frame_for` is a pure
|
|
115
|
+
# static mapper with no agent in scope — the profile is read once, here.
|
|
116
|
+
def emit_intermediate(text)
|
|
117
|
+
data = { delta: text }
|
|
118
|
+
data[:public] = true if @public_intermediate
|
|
119
|
+
@emit.call(:intermediate, data)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def assistant?(message) = field(message, :role).to_s == "assistant"
|
|
123
|
+
|
|
124
|
+
# RubyLLM::Message answers `tool_call?`; a double may only carry the field.
|
|
125
|
+
def tool_calls?(message)
|
|
126
|
+
return !!message.tool_call? if message.respond_to?(:tool_call?)
|
|
127
|
+
|
|
128
|
+
calls = field(message, :tool_calls)
|
|
129
|
+
calls.respond_to?(:empty?) ? !calls.empty? : !calls.nil?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def field(message, name)
|
|
133
|
+
return message.public_send(name) if message.respond_to?(name)
|
|
134
|
+
return message[name] || message[name.to_s] if message.respond_to?(:[])
|
|
135
|
+
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# MUTABLE on purpose (the only exception to the Data types):
|
|
5
|
+
# the Middleware MODIFIES the execution — the links write into these fields.
|
|
6
|
+
class TurnState
|
|
7
|
+
attr_reader :task, :profile, :turn # turn identity (1-based)
|
|
8
|
+
attr_accessor :message, # input (Middleware may rewrite)
|
|
9
|
+
:context, # ContextPackage from the Builder
|
|
10
|
+
:allowed_tools, # Resolution from the Policy Engine
|
|
11
|
+
:allowed_skills,
|
|
12
|
+
:chat, # the turn's RubyLLM::Chat instance
|
|
13
|
+
:session, # the turn's SessionStore::Session | nil (set at stage 2;
|
|
14
|
+
# read by create_chat for the per-chat model pin)
|
|
15
|
+
:model_selection, # resolved ModelSelection: model/provider/source/
|
|
16
|
+
# pinned/params/fallbacks. Set at stage 5; surfaced in usage.
|
|
17
|
+
:halt_reason, # set by Middleware when short-circuiting (halt-as-FAILURE)
|
|
18
|
+
:halt_response, # set by a Middleware for the GRACEFUL halt:
|
|
19
|
+
# the safe reply the turn completes with, WITHOUT touching the
|
|
20
|
+
# LLM. Distinct from halt_reason — a completion, not a failure.
|
|
21
|
+
:guardrail_block, # audit metadata the guardrail sets alongside halt_response
|
|
22
|
+
# ({category:, source:, action:, detail:}); the Executor (single
|
|
23
|
+
# emitter) turns it into :guardrail_blocked.
|
|
24
|
+
:guardrail_flags, # [{category:, source:, detail:}] appended by the OutputValidator
|
|
25
|
+
# (after_task); the Executor emits one :guardrail_flagged each.
|
|
26
|
+
:response_content, # the turn's final assistant text, set at stage 6/on halt so the
|
|
27
|
+
# after_task validator can inspect it.
|
|
28
|
+
:output_filter, # per-turn Safety::OutputFilter (nil = off); redacts the stream.
|
|
29
|
+
:stuck_outcome # set by the signal_stuck system tool (WS5):
|
|
30
|
+
# { reason:, message: } when the agent declared it cannot
|
|
31
|
+
# proceed. The Executor tags the terminal event with
|
|
32
|
+
# outcome: "stuck" and emits :turn_stuck. nil = normal turn.
|
|
33
|
+
|
|
34
|
+
# Internal (not part of the contract): per-CALL correlation between RubyLLM's
|
|
35
|
+
# tool callbacks and the tool decorators — `current_tool_call` keys the
|
|
36
|
+
# side-effect checkpoint / resume skip / trace, `current_tool_name` labels the
|
|
37
|
+
# :tool_result event.
|
|
38
|
+
#
|
|
39
|
+
# They live in FIBER STORAGE, not in ivars, and that is the whole point:
|
|
40
|
+
# `before_tool_call` → `tool.call` → `after_tool_result` all run in the SAME
|
|
41
|
+
# fiber, and with `ToolConcurrency` there is one fiber PER CALL. A
|
|
42
|
+
# single slot on this shared object would let one in-flight call overwrite
|
|
43
|
+
# another's — a side-effect recorded under the wrong id (so a resume skips the
|
|
44
|
+
# wrong tool, or re-runs a non-idempotent one) and a mislabelled event. Both
|
|
45
|
+
# silent. One writer per fiber needs no lock; serial execution is unchanged,
|
|
46
|
+
# since a lone fiber writes and reads its own storage.
|
|
47
|
+
#
|
|
48
|
+
# Read/written ONLY through here so the rule has one home.
|
|
49
|
+
CALL_KEY = :insika_tool_call
|
|
50
|
+
NAME_KEY = :insika_tool_name
|
|
51
|
+
|
|
52
|
+
def current_tool_call = Fiber[CALL_KEY]
|
|
53
|
+
def current_tool_name = Fiber[NAME_KEY]
|
|
54
|
+
|
|
55
|
+
def current_tool_call=(call)
|
|
56
|
+
Fiber[CALL_KEY] = call
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def current_tool_name=(name)
|
|
60
|
+
Fiber[NAME_KEY] = name
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Internal (R1): the chat's message count RIGHT AFTER `assemble` (seeded
|
|
64
|
+
# history) and BEFORE `ask`. persist_turn slices `chat.messages.drop(baseline)`
|
|
65
|
+
# to serialize the turn's real exchange — user + assistant(tool_calls) + tool
|
|
66
|
+
# results + final assistant — into the transcript. nil = no chat recorded
|
|
67
|
+
# (workflow/halt) → persist_turn falls back to the {user, assistant} pair.
|
|
68
|
+
attr_accessor :chat_baseline
|
|
69
|
+
|
|
70
|
+
# Internal: impl_name(String) -> STABLE name of the capability that
|
|
71
|
+
# resolved it, computed by resolve_capabilities BEFORE the policy_request and
|
|
72
|
+
# consulted AFTER @policy_engine.decide, at the post-Policy junction, to
|
|
73
|
+
# decide which impls enter as Capability::ResolvedTool. {} = no
|
|
74
|
+
# capability_registry or empty profile.capabilities (parity).
|
|
75
|
+
attr_accessor :capability_names
|
|
76
|
+
|
|
77
|
+
# Internal: the turn's resolved QueuePolicy. Read at stage 6 to decide
|
|
78
|
+
# whether this run accepts steered messages, and how they are worded. Resolved once
|
|
79
|
+
# per turn, in build_turn_state — an edit to the agent mid-run does not change the
|
|
80
|
+
# rules the run started under.
|
|
81
|
+
attr_accessor :queue_policy
|
|
82
|
+
|
|
83
|
+
# Internal: true when this turn re-enters the pipeline via
|
|
84
|
+
# resume_task/recovery. The EdgeLimiter reads it to NEVER re-count or block a
|
|
85
|
+
# turn that was already admitted — a crash/pause under a saturated window must
|
|
86
|
+
# not swallow a legitimate message with the rate-limit reply.
|
|
87
|
+
attr_accessor :resumed
|
|
88
|
+
|
|
89
|
+
# Internal (memory): the turn's tenant (from the Command), scope of the write path
|
|
90
|
+
# (`remember` tool). Set in run_pipeline; nil = DEFAULT_TENANT in the MemoryStore.
|
|
91
|
+
attr_accessor :tenant
|
|
92
|
+
|
|
93
|
+
# Internal: turn context deposited into the data-tools to
|
|
94
|
+
# resolve {{ctx.*}} (chat_id/agent_id/tenant/store_id) and emit
|
|
95
|
+
# X-Chat-Id/X-Store-Id/X-Agent-Id. A Hash of symbols, set in run_pipeline.
|
|
96
|
+
# Comes from the TURN, never from the model's args (R2). Distinct from `tenant` (memory).
|
|
97
|
+
attr_accessor :turn_context
|
|
98
|
+
|
|
99
|
+
# Internal (observability): the turn's token usage (input/output/
|
|
100
|
+
# total/cached + model), captured from the provider's response at stage 6. Goes
|
|
101
|
+
# to the terminal event (:task_completed) — feeds the usage of
|
|
102
|
+
# /v1/responses and the Telemetry (OTEL). nil = turn with no model response
|
|
103
|
+
# (workflow) or provider without counts.
|
|
104
|
+
attr_accessor :usage
|
|
105
|
+
|
|
106
|
+
# Internal (Tool Search): ids of side-effects already completed in the
|
|
107
|
+
# interrupted turn, propagated to the tools PROMOTED by tool_search (the same `skip`
|
|
108
|
+
# that the eager tools' wrap_tools receives). Set in run_pipeline;
|
|
109
|
+
# nil = new turn (Array(nil) => []).
|
|
110
|
+
attr_accessor :skip_side_effects
|
|
111
|
+
|
|
112
|
+
# Approval gate. `requires_approval` = names of tools that require
|
|
113
|
+
# approval (Resolution); `approval_coordinator` = object (the Executor) that
|
|
114
|
+
# creates the PendingAction/suspends/waits; `actor` = the turn's mailbox (used
|
|
115
|
+
# by the coordinator for await(:approval)).
|
|
116
|
+
attr_accessor :requires_approval, :approval_coordinator, :actor
|
|
117
|
+
|
|
118
|
+
# Internal: the turn's shared in-flight cap for tool calls —
|
|
119
|
+
# ONE Async::Semaphore(tool_concurrency), installed by ToolAssembly#wrap_tools
|
|
120
|
+
# and acquired by every ToolEnvelope, INCLUDING the ones tool_search promotes
|
|
121
|
+
# mid-turn (they read it off the state, so the cap survives promotion).
|
|
122
|
+
# nil = concurrency off: no gate, no overhead, serial execution unchanged.
|
|
123
|
+
attr_accessor :tool_gate
|
|
124
|
+
|
|
125
|
+
# parallel tool calls, resolved PER TURN and read by ChatBuilder
|
|
126
|
+
# (whether to hand the gem `concurrency:`) and ToolAssembly (the gate's size).
|
|
127
|
+
#
|
|
128
|
+
# `requested_tool_concurrency` is what the operator configured;
|
|
129
|
+
# `tool_concurrency` is what this turn actually gets. They differ for exactly
|
|
130
|
+
# one reason —: `Executor#request_approval` blocks on `actor.await(approval)`,
|
|
131
|
+
# and the mailbox is one queue per TASK. Two fibers waiting there share it,
|
|
132
|
+
# `dequeue` wakes exactly one, the message is consumed, and the other fiber
|
|
133
|
+
# hangs until `approval_timeout` (~1h). So a turn that can suspend for a human
|
|
134
|
+
# runs its tools serially. Per-TURN and not per-profile because
|
|
135
|
+
# `requires_approval` comes from the Resolution: it can be empty on a turn
|
|
136
|
+
# whose profile does list approvals.
|
|
137
|
+
def requested_tool_concurrency
|
|
138
|
+
n = ((profile.respond_to?(:limits) && profile.limits) || {})[:tool_concurrency].to_i
|
|
139
|
+
n > 1 ? n : nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def tool_concurrency
|
|
143
|
+
return nil unless Array(requires_approval).empty?
|
|
144
|
+
|
|
145
|
+
requested_tool_concurrency
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def initialize(task:, profile:, turn:, message:)
|
|
149
|
+
@task = task
|
|
150
|
+
@profile = profile
|
|
151
|
+
@turn = turn
|
|
152
|
+
@message = message
|
|
153
|
+
@capability_names = {}
|
|
154
|
+
# Fiber storage is INHERITED by fibers created later, so a turn spawned from
|
|
155
|
+
# inside a tool call (a subagent child) would start out carrying its
|
|
156
|
+
# parent's correlation. Clearing at turn start keeps a child from keying its
|
|
157
|
+
# own side-effects under the parent's tool_call id.
|
|
158
|
+
self.current_tool_call = nil
|
|
159
|
+
self.current_tool_name = nil
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Opt-in per-turn latency breakdown (locate the
|
|
5
|
+
# TTFB cost with real-turn data). OFF unless INSIKA_TURN_TIMING is set — when
|
|
6
|
+
# off the Executor never allocates one and the hot path pays only `nil&.mark`.
|
|
7
|
+
#
|
|
8
|
+
# Splits a turn into the three windows that answer "is TTFB local or provider?":
|
|
9
|
+
# prep_ms — prep_start -> ask: ALL local work before the provider call
|
|
10
|
+
# (context build, policy, guardrail detectors, chat assembly).
|
|
11
|
+
# ttft_ms — ask -> first_token: the provider round-trip to the 1st token.
|
|
12
|
+
# gen_ms — first_token -> done: streaming the rest of the response.
|
|
13
|
+
#
|
|
14
|
+
# Marks are monotonic; `mark` is first-write-wins so `first_token` records the
|
|
15
|
+
# FIRST content chunk even though it is called on every chunk.
|
|
16
|
+
#
|
|
17
|
+
# `ttft_ms` is the PROVIDER's first token, not the first byte the customer can
|
|
18
|
+
# read: TurnOutput publishes a message once it ends, so the customer-visible
|
|
19
|
+
# answer lands inside `gen_ms`. Measuring the provider is the point —'s
|
|
20
|
+
# baselines (~720 ms, provider-bound) stay comparable across that change.
|
|
21
|
+
class TurnTiming
|
|
22
|
+
# EnvSchema owns "is this flag on?" (1/true/yes/on) — the same predicate that
|
|
23
|
+
# validates the :boolean keys, so a spelling `insika env` accepts is a spelling
|
|
24
|
+
# the reader honours.
|
|
25
|
+
def self.enabled?(env = ENV)
|
|
26
|
+
Insika::EnvSchema.truthy?(Insika::EnvSchema.read("INSIKA_TURN_TIMING", env))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@marks = {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def mark(name)
|
|
34
|
+
@marks[name] ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# -> Hash of phase deltas in ms (only the windows whose endpoints both fired;
|
|
38
|
+
# a workflow turn has no ask/first_token, so those are simply absent).
|
|
39
|
+
def to_h
|
|
40
|
+
{
|
|
41
|
+
prep_ms: delta(:prep_start, :ask),
|
|
42
|
+
ttft_ms: delta(:ask, :first_token),
|
|
43
|
+
gen_ms: delta(:first_token, :done),
|
|
44
|
+
total_ms: delta(:prep_start, :done)
|
|
45
|
+
}.compact
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def delta(from, to)
|
|
51
|
+
return nil unless @marks[from] && @marks[to]
|
|
52
|
+
|
|
53
|
+
((@marks[to] - @marks[from]) * 1000).round(2)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Fixed-window counters on the KV Store: the durable side of
|
|
5
|
+
# the edge limits. One scope, keys shaped "kind:id:window_start" — the window
|
|
6
|
+
# start bucketed on the epoch keeps every process/worker on the SAME bucket
|
|
7
|
+
# without coordination (the Store's transaction serializes the read-modify-write,
|
|
8
|
+
# in-process via the fiber semaphore and cross-process via BEGIN IMMEDIATE).
|
|
9
|
+
#
|
|
10
|
+
# Growth is bounded: each `add` garbage-collects the (kind, id) pair's PREVIOUS
|
|
11
|
+
# window key, so an active entity holds at most 2 keys at any time and an idle
|
|
12
|
+
# one converges to 1.
|
|
13
|
+
class UsageLedger
|
|
14
|
+
SCOPE = "usage_counters"
|
|
15
|
+
|
|
16
|
+
def initialize(store:)
|
|
17
|
+
@store = store
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Adds `by` to the current window's counter of (kind, id) -> the NEW total.
|
|
21
|
+
def add(kind, id, window:, by: 1, now: Time.now)
|
|
22
|
+
start = window_start(now, window)
|
|
23
|
+
key = key_for(kind, id, start)
|
|
24
|
+
@store.transaction do
|
|
25
|
+
total = @store.get(SCOPE, key).to_i + by
|
|
26
|
+
@store.set(SCOPE, key, total)
|
|
27
|
+
@store.delete(SCOPE, key_for(kind, id, start - window))
|
|
28
|
+
total
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Current window's total for (kind, id). Missing/expired -> 0.
|
|
33
|
+
def count(kind, id, window:, now: Time.now)
|
|
34
|
+
@store.get(SCOPE, key_for(kind, id, window_start(now, window))).to_i
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def window_start(now, window)
|
|
40
|
+
(now.to_i / window) * window
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def key_for(kind, id, start)
|
|
44
|
+
"#{kind}:#{id}:#{start}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/insika/version.rb
CHANGED