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,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Context
|
|
5
|
+
module Providers
|
|
6
|
+
# Reads the Session Store. The ONLY history provider: the three transcript
|
|
7
|
+
# sources converge here — the Executor does not pick a source. Produces
|
|
8
|
+
# :history fragments (1 per message), with priority scaled by recency
|
|
9
|
+
# with a CEILING of 79: the budget cut discards the oldest ones first
|
|
10
|
+
# and history NEVER outranks skills (80) or identity (100).
|
|
11
|
+
class Session < ContextProvider
|
|
12
|
+
def initialize(session_store:)
|
|
13
|
+
@session_store = session_store
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(request)
|
|
17
|
+
messages = transcript_for(request)
|
|
18
|
+
return [] if messages.nil? || messages.empty?
|
|
19
|
+
|
|
20
|
+
# A3/C3 opt-in: identical tool results in the transcript collapse to a
|
|
21
|
+
# back-reference (the cheap half of compaction). CHANGES WHAT THE MODEL
|
|
22
|
+
# SEES — hence the profile flag, never a default. Applied BEFORE the
|
|
23
|
+
# eviction-unit grouping so a cycle's results are already slim.
|
|
24
|
+
messages = compress_history(messages, request)
|
|
25
|
+
|
|
26
|
+
# 1 fragment per EVICTION UNIT (R1): a plain message, OR an
|
|
27
|
+
# assistant-with-tool_calls together with its tool results. Grouping at
|
|
28
|
+
# the fragment level means the budget cut (apply_budget) drops a whole
|
|
29
|
+
# tool cycle atomically — a tool_use is NEVER seeded without its result
|
|
30
|
+
# (which providers reject), without touching apply_budget itself.
|
|
31
|
+
eviction_units(messages).each_with_index.map do |unit, idx|
|
|
32
|
+
ContextFragment.build(
|
|
33
|
+
# single message stays a Hash (compat with existing fragments); a
|
|
34
|
+
# multi-message cycle is an Array (seed_history flattens it back).
|
|
35
|
+
content: unit.size == 1 ? unit.first : unit,
|
|
36
|
+
placement: :history,
|
|
37
|
+
# HISTORY_MAX ceiling; idx 0 = oldest (drops first in the cut)
|
|
38
|
+
priority: [Context::Priority::HISTORY_BASE + idx, Context::Priority::HISTORY_MAX].min,
|
|
39
|
+
source: id
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Groups a flat message list into eviction units. An assistant message
|
|
47
|
+
# carrying tool_calls absorbs the `role: tool` messages that immediately
|
|
48
|
+
# follow it (its results). Everything else is a unit of one.
|
|
49
|
+
def eviction_units(messages)
|
|
50
|
+
normalized = messages.map { |m| normalize(m) }
|
|
51
|
+
units = []
|
|
52
|
+
i = 0
|
|
53
|
+
while i < normalized.length
|
|
54
|
+
msg = normalized[i]
|
|
55
|
+
i += 1
|
|
56
|
+
if msg[:role].to_s == "assistant" && tool_calls?(msg)
|
|
57
|
+
cycle = [msg]
|
|
58
|
+
while i < normalized.length && normalized[i][:role].to_s == "tool"
|
|
59
|
+
cycle << normalized[i]
|
|
60
|
+
i += 1
|
|
61
|
+
end
|
|
62
|
+
units << cycle
|
|
63
|
+
else
|
|
64
|
+
units << [msg]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
units
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Symbol-keyed message preserving tool_calls / tool_call_id when present
|
|
71
|
+
# (a plain message keeps just {role, content} — parity with the old shape).
|
|
72
|
+
def normalize(msg)
|
|
73
|
+
h = { role: msg[:role] || msg["role"], content: msg[:content] || msg["content"] }
|
|
74
|
+
tool_calls = msg[:tool_calls] || msg["tool_calls"]
|
|
75
|
+
tool_call_id = msg[:tool_call_id] || msg["tool_call_id"]
|
|
76
|
+
h[:tool_calls] = tool_calls if tool_calls
|
|
77
|
+
h[:tool_call_id] = tool_call_id if tool_call_id
|
|
78
|
+
h
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def tool_calls?(msg) = msg[:tool_calls] && !Array(msg[:tool_calls]).empty?
|
|
82
|
+
|
|
83
|
+
# The compression is opt-in per agent (profile data, config-over-code):
|
|
84
|
+
# absent/off -> the transcript passes through byte-identical (parity).
|
|
85
|
+
def compress_history(messages, request)
|
|
86
|
+
profile = request.respond_to?(:profile) ? request.profile : nil
|
|
87
|
+
return messages unless profile&.tool_output_compression
|
|
88
|
+
|
|
89
|
+
ToolOutputCompressor.compress_transcript(messages)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Precedence: checkpoint -> explicit history -> store.
|
|
93
|
+
# The first present source wins; no merge.
|
|
94
|
+
def transcript_for(request)
|
|
95
|
+
return request.checkpoint.messages if request.checkpoint
|
|
96
|
+
|
|
97
|
+
explicit = explicit_history(request)
|
|
98
|
+
return explicit if explicit
|
|
99
|
+
return session_messages(request.session) if request.session
|
|
100
|
+
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Source 2 (explicit history): the handler passes it in request.vars[:history].
|
|
105
|
+
# Isolated in a single method to change the convention with 1 line.
|
|
106
|
+
def explicit_history(request)
|
|
107
|
+
vars = request.vars.to_h
|
|
108
|
+
vars[:history] || vars["history"]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# CONDITIONAL requiredness: when a session is requested, a read
|
|
112
|
+
# failure becomes a ContextError (aborts the turn); the base required?
|
|
113
|
+
# does not receive the request, so the behavior lives here.
|
|
114
|
+
def session_messages(session)
|
|
115
|
+
@session_store.find(session.id)&.messages || []
|
|
116
|
+
rescue StandardError => e # read failure (exception/StoreError)
|
|
117
|
+
raise ContextError.new("Session provider failed with a requested session: #{e.message}",
|
|
118
|
+
provider: id)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Context
|
|
5
|
+
module Providers
|
|
6
|
+
# Level 1 (progressive disclosure) of the profile's CANDIDATE skills.
|
|
7
|
+
# The Executor's LoadSkill does NOT come from here: it is built with
|
|
8
|
+
# resolution.allowed_skills (a policy decision). Context->Policy order: the
|
|
9
|
+
# provider produces the candidate; the policy cuts afterwards.
|
|
10
|
+
class Skill < CatalogProvider
|
|
11
|
+
# priority 80: above deferred tools (70), below pinned identity.
|
|
12
|
+
def priority = Context::Priority::SKILL
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Only the skills the model still has to ASK for. An eager skill is already in
|
|
17
|
+
# the prompt in full, so advertising it here would invite a `load_skill` call
|
|
18
|
+
# that buys a duplicate — and the catalog's whole job is to describe what is
|
|
19
|
+
# NOT yet loaded. Nothing lazy left -> CatalogProvider emits no fragment.
|
|
20
|
+
def entries(request) = @catalog.lazy_for(request.profile)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Context
|
|
5
|
+
module Providers
|
|
6
|
+
# Level-2 skill BODIES in the prompt, with two selection modes — both
|
|
7
|
+
# deterministic, neither asks the model:
|
|
8
|
+
#
|
|
9
|
+
# profile.skills_eager -> the agent's eager set (all, or a named list),
|
|
10
|
+
# every turn. No decision at all, so no miss rate.
|
|
11
|
+
# Costs the bodies' tokens.
|
|
12
|
+
# `triggers:` in the -> only the skills whose trigger matches this
|
|
13
|
+
# frontmatter message (whole word, accent- and case-insensitive).
|
|
14
|
+
#
|
|
15
|
+
# Model-invoked loading (load_skill) stays the fallback for everything else.
|
|
16
|
+
#
|
|
17
|
+
# A trigger only belongs on a skill that can COMPLETE the turn by itself.
|
|
18
|
+
# Injecting a reference table whose companion skill holds the procedure is
|
|
19
|
+
# worse than injecting nothing: the model has a plausible half-recipe in the
|
|
20
|
+
# prompt, so it never calls load_skill for the other half. Measured on a real
|
|
21
|
+
# pack — the line map arrived, the query-construction rules did not, and the
|
|
22
|
+
# searches came out malformed.
|
|
23
|
+
# Activation here is NOT a tool call, so nothing in the transcript would show
|
|
24
|
+
# it. The fragment's `labels` carry the name AND THE REASON, and the EXECUTOR
|
|
25
|
+
# announces them (:skill_activated) — not this provider. A provider only has the
|
|
26
|
+
# ContextRequest, which has no task, and the Studio's SSE drops an event whose
|
|
27
|
+
# meta lacks `task_id` when the subscriber is task-scoped (Subscription#matches?):
|
|
28
|
+
# emitting from here produced an event that was correct and never arrived.
|
|
29
|
+
class SkillTrigger < ContextProvider
|
|
30
|
+
def initialize(catalog:)
|
|
31
|
+
@catalog = catalog
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(request)
|
|
35
|
+
matched = select(request)
|
|
36
|
+
return [] if matched.empty?
|
|
37
|
+
|
|
38
|
+
content = matched.map do |skill, _reason|
|
|
39
|
+
%(<active_skill name="#{skill.name}">\n#{skill.body}\n</active_skill>)
|
|
40
|
+
end.join("\n\n")
|
|
41
|
+
|
|
42
|
+
labels = matched.map { |skill, reason| { "name" => skill.name, "reason" => reason } }
|
|
43
|
+
[ContextFragment.build(content: content, placement: :system,
|
|
44
|
+
priority: Context::Priority::SKILL_BODY, source: id,
|
|
45
|
+
labels: labels)]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# -> [[skill, reason]]. Two independent reasons a body lands here: the AGENT
|
|
51
|
+
# marked it eager (always), or a `triggers:` entry matched THIS message.
|
|
52
|
+
# The union is injected; everything else stays at level 1 for load_skill, where
|
|
53
|
+
# the model's call is the only record of what it reached for.
|
|
54
|
+
#
|
|
55
|
+
# The reason travels with the skill from here to the activation card, because
|
|
56
|
+
# "which skills were active" without "why each one was" is the information the
|
|
57
|
+
# deterministic paths destroyed when they replaced the load_skill call.
|
|
58
|
+
def select(request)
|
|
59
|
+
eager = @catalog.eager_for(request.profile).map { |skill| [skill, "eager"] }
|
|
60
|
+
# `triggered` reads the LAZY set, which excludes the eager one — so a skill
|
|
61
|
+
# cannot arrive twice. uniq_by name anyway: the invariant is worth not
|
|
62
|
+
# depending on from here.
|
|
63
|
+
selected = (eager + triggered(request)).uniq { |skill, _reason| skill.name }
|
|
64
|
+
(selected + companions(selected, request)).uniq { |skill, _reason| skill.name }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Declared `companions:` travel with whatever brought them, so the half-recipe
|
|
68
|
+
# state cannot be assembled: the line map that arrived by trigger takes its
|
|
69
|
+
# query-construction rules with it. ONE LEVEL, deliberately — a transitive walk
|
|
70
|
+
# would make a cycle a hang and a chain a budget blowout, and "cannot work
|
|
71
|
+
# without" is a direct relationship.
|
|
72
|
+
#
|
|
73
|
+
# Restricted to the agent's own allowed set: a companion the agent cannot see
|
|
74
|
+
# is not injectable, and `doctor` flags that declaration rather than the engine
|
|
75
|
+
# quietly widening the allowlist.
|
|
76
|
+
def companions(selected, request)
|
|
77
|
+
wanted = selected.flat_map { |skill, _reason| Array(skill.companions).map { |c| [c.to_s, skill.name] } }
|
|
78
|
+
return [] if wanted.empty?
|
|
79
|
+
|
|
80
|
+
by_name = @catalog.effective(request.profile.skills, agent: request.profile.id)
|
|
81
|
+
.each_with_object({}) { |s, acc| acc[s.name] = s }
|
|
82
|
+
wanted.filter_map do |name, of|
|
|
83
|
+
skill = by_name[name]
|
|
84
|
+
[skill, "companion:#{of}"] if skill
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def triggered(request)
|
|
89
|
+
message = fold(request.message)
|
|
90
|
+
return [] if message.empty?
|
|
91
|
+
|
|
92
|
+
@catalog.lazy_for(request.profile).filter_map do |skill|
|
|
93
|
+
phrase = matched_trigger(skill, message)
|
|
94
|
+
[skill, "trigger:#{phrase}"] if phrase
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The trigger phrase that fired, or nil. AS AUTHORED, never as typed: the
|
|
99
|
+
# reason lands on the activation card, and what an operator needs there is the
|
|
100
|
+
# config line they can go and edit — not an echo of the customer's message
|
|
101
|
+
# (which also keeps the label content-free, like the rest of the trace).
|
|
102
|
+
#
|
|
103
|
+
# Two hygiene rules, and both are tokenization rather than the semantic
|
|
104
|
+
# matching this feature deliberately does not do:
|
|
105
|
+
#
|
|
106
|
+
# whole word — bare substring made `triggers: presente` fire inside
|
|
107
|
+
# *apresente*, and the card now PRINTS the matched phrase: `trigger:presente`
|
|
108
|
+
# on a turn about *apresentação* would discredit the card on day one.
|
|
109
|
+
#
|
|
110
|
+
# folded accents — the corpus is Portuguese and customers type *maquiagem*
|
|
111
|
+
# and *maquiágem* unpredictably, so both sides are folded before comparing.
|
|
112
|
+
def matched_trigger(skill, folded_message)
|
|
113
|
+
skill.triggers.find do |trigger|
|
|
114
|
+
needle = fold(trigger)
|
|
115
|
+
next false if needle.empty?
|
|
116
|
+
|
|
117
|
+
/(?<![[:alnum:]])#{Regexp.escape(needle)}(?![[:alnum:]])/.match?(folded_message)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# NFD splits an accented letter into letter + combining mark; dropping the
|
|
122
|
+
# marks (\p{Mn}) leaves the bare letter, so "maquiágem" and "maquiagem" fold
|
|
123
|
+
# to the same string.
|
|
124
|
+
def fold(text) = text.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, "").downcase
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Context
|
|
5
|
+
module Providers
|
|
6
|
+
# Level 1 (progressive disclosure) of the profile's deferred TOOLS. The
|
|
7
|
+
# slice is `profile.tools_deferred`, known at stage 2 — it may be slightly
|
|
8
|
+
# over-inclusive (showing a deferred tool the Policy later denies); acceptable,
|
|
9
|
+
# the real cut happens at PROMOTION (tool_search, `deferred ∩ allowed`).
|
|
10
|
+
class ToolSearch < CatalogProvider
|
|
11
|
+
# priority 70: below skills (80) in the sacrifice order.
|
|
12
|
+
def priority = Context::Priority::TOOL_SEARCH
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def entries(request) = @catalog.subset(request.profile.tools_deferred)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Per-session CONTEXT trace, for the Studio's breakdown-by-category card
|
|
5
|
+
# One record per session in the raw backend (scope
|
|
6
|
+
# "context_traces") — RUNTIME data, next to sessions/tasks, like the
|
|
7
|
+
# ToolTraceStore it mirrors. A capped LIST of entries, ONE PER TURN, written
|
|
8
|
+
# by the Executor after tool assembly.
|
|
9
|
+
#
|
|
10
|
+
# Unlike the tool trace there is NO security machinery here by construction:
|
|
11
|
+
# the entry is counts and provider ids only (category -> tokens/fragments/
|
|
12
|
+
# pinned, the tools estimate, the budget verdict) — never fragment content,
|
|
13
|
+
# so there is nothing to mask. `record` still rescues everything: the trace
|
|
14
|
+
# NEVER breaks the turn.
|
|
15
|
+
class ContextTraceStore
|
|
16
|
+
SCOPE = "context_traces"
|
|
17
|
+
MAX_PER_SESSION = 50 # one per turn; turns are the unit, not tool calls
|
|
18
|
+
|
|
19
|
+
def initialize(store:)
|
|
20
|
+
@store = store
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Writes an entry into the session — UPSERT by (task_id, turn): a turn that
|
|
24
|
+
# suspends (approval) and resumes re-runs the context stage, and the
|
|
25
|
+
# re-record replaces the first one instead of duplicating it. (`turn` is
|
|
26
|
+
# 1-based PER TASK, so the task is part of the key.) Missing session_id ->
|
|
27
|
+
# no-op.
|
|
28
|
+
def record(session_id:, entry:)
|
|
29
|
+
sid = session_id.to_s
|
|
30
|
+
return if sid.empty?
|
|
31
|
+
|
|
32
|
+
e = sanitize(entry)
|
|
33
|
+
key = [e["task_id"], e["turn"]]
|
|
34
|
+
list = (@store.get(SCOPE, sid) || []).reject { |x| [x["task_id"], x["turn"]] == key } + [e]
|
|
35
|
+
@store.set(SCOPE, sid, list.last(MAX_PER_SESSION))
|
|
36
|
+
rescue StandardError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# -> [Hash] session entries in chronological order. [] if none.
|
|
41
|
+
def for_session(session_id) = @store.get(SCOPE, session_id.to_s) || []
|
|
42
|
+
|
|
43
|
+
# Discards a session's trace (cleanup). -> bool (did it exist?).
|
|
44
|
+
def clear(session_id) = @store.delete(SCOPE, session_id.to_s)
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Keeps only the known shape; coerces numbers and strings so a caller bug
|
|
49
|
+
# degrades the card instead of poisoning the record.
|
|
50
|
+
def sanitize(entry)
|
|
51
|
+
e = entry.is_a?(Hash) ? entry : {}
|
|
52
|
+
categories = e[:categories] || e["categories"] || {}
|
|
53
|
+
tools = e[:tools] || e["tools"] || {}
|
|
54
|
+
{
|
|
55
|
+
"task_id" => (e[:task_id] || e["task_id"]).to_s,
|
|
56
|
+
"turn" => int(e[:turn] || e["turn"]),
|
|
57
|
+
"at" => (e[:at] || e["at"]).to_s,
|
|
58
|
+
"cap" => int(e[:cap] || e["cap"]),
|
|
59
|
+
"used" => int(e[:used] || e["used"]),
|
|
60
|
+
"evicted" => Array(e[:evicted] || e["evicted"]).map(&:to_s),
|
|
61
|
+
"categories" => categories.each_with_object({}) do |(name, c), acc|
|
|
62
|
+
c = {} unless c.is_a?(Hash)
|
|
63
|
+
cat = { "tokens" => int(c[:tokens] || c["tokens"]),
|
|
64
|
+
"fragments" => int(c[:fragments] || c["fragments"]),
|
|
65
|
+
"pinned" => int(c[:pinned] || c["pinned"]) }
|
|
66
|
+
# WHAT the category carried and WHY ({name, reason}) — still ids only, so
|
|
67
|
+
# the no-masking-needed contract above holds. Omitted when empty: most
|
|
68
|
+
# categories have nothing to name and an empty key is just noise.
|
|
69
|
+
labels = normalize_labels(c[:labels] || c["labels"])
|
|
70
|
+
cat["labels"] = labels unless labels.empty?
|
|
71
|
+
acc[name.to_s] = cat
|
|
72
|
+
end,
|
|
73
|
+
"tools" => { "count" => int(tools[:count] || tools["count"]),
|
|
74
|
+
"tokens" => int(tools[:tokens] || tools["tokens"]) }
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Labels are {name, reason} in string keys (ContextFragment.label). A bare string
|
|
79
|
+
# still reads as a nameless-reason label: an entry recorded before reasons existed,
|
|
80
|
+
# or a caller that only has the id, degrades the card instead of poisoning it.
|
|
81
|
+
def normalize_labels(raw)
|
|
82
|
+
Array(raw).filter_map do |label|
|
|
83
|
+
name, reason = label.is_a?(Hash) ? [label[:name] || label["name"], label[:reason] || label["reason"]] : [label, nil]
|
|
84
|
+
next if name.to_s.empty?
|
|
85
|
+
|
|
86
|
+
{ "name" => name.to_s, "reason" => reason&.to_s }.compact
|
|
87
|
+
end.uniq
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def int(value) = Integer(value || 0)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Durable record of an ASYNC delegation (hermes
|
|
8
|
+
# "delegation durability"). The synchronous subagent needs no
|
|
9
|
+
# record — it lives and dies inside the parent's turn. The ASYNC subagent does:
|
|
10
|
+
# the parent DISPATCHES and its turn ends; the child runs independently; when the
|
|
11
|
+
# child finishes, its result is delivered to the parent as a NEW turn (never
|
|
12
|
+
# spliced mid-turn — preserves role alternation + prompt cache). This record is
|
|
13
|
+
# what survives a kill -9 so a completed child's result is never lost.
|
|
14
|
+
#
|
|
15
|
+
# Lifecycle (never backwards): dispatched -> completed -> delivered.
|
|
16
|
+
# dispatched the child was spawned; result not captured yet.
|
|
17
|
+
# completed the child is terminal; result/error captured durably.
|
|
18
|
+
# delivered the result was handed to the parent session (claimed — the
|
|
19
|
+
# claim is what makes delivery AT-MOST-ONCE across crashes).
|
|
20
|
+
#
|
|
21
|
+
# Normalizes symbol->string on WRITE (the backend only round-trips JSON types),
|
|
22
|
+
# like the other domain stores.
|
|
23
|
+
class DelegationStore
|
|
24
|
+
include Coercion
|
|
25
|
+
|
|
26
|
+
SCOPE = "delegations"
|
|
27
|
+
KEY_PREFIX = "delegation:"
|
|
28
|
+
|
|
29
|
+
STATUSES = %i[dispatched completed delivered].freeze
|
|
30
|
+
|
|
31
|
+
Delegation = Data.define(
|
|
32
|
+
:id, :parent_task_id, :parent_session_id, :parent_agent,
|
|
33
|
+
:child_agent, :child_task_id, :child_session_id, :depth,
|
|
34
|
+
:status, :result, :error, :created_at, :updated_at
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def initialize(store:)
|
|
38
|
+
@store = store
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# -> Delegation (:dispatched).
|
|
42
|
+
def create(parent_task_id:, parent_session_id:, parent_agent:, child_agent:,
|
|
43
|
+
child_task_id:, child_session_id:, depth:, id: SecureRandom.uuid)
|
|
44
|
+
record = {
|
|
45
|
+
"id" => id.to_s,
|
|
46
|
+
"parent_task_id" => parent_task_id.to_s,
|
|
47
|
+
"parent_session_id" => parent_session_id&.to_s,
|
|
48
|
+
"parent_agent" => parent_agent.to_s,
|
|
49
|
+
"child_agent" => child_agent.to_s,
|
|
50
|
+
"child_task_id" => child_task_id.to_s,
|
|
51
|
+
"child_session_id" => child_session_id.to_s,
|
|
52
|
+
"depth" => depth,
|
|
53
|
+
"status" => "dispatched",
|
|
54
|
+
"result" => nil,
|
|
55
|
+
"error" => nil,
|
|
56
|
+
"created_at" => timestamp,
|
|
57
|
+
"updated_at" => timestamp
|
|
58
|
+
}
|
|
59
|
+
@store.set(SCOPE, key_for(id), record)
|
|
60
|
+
to_delegation(record)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# -> Delegation | nil
|
|
64
|
+
def find(id)
|
|
65
|
+
record = @store.get(SCOPE, key_for(id))
|
|
66
|
+
record && to_delegation(record)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# -> Delegation | nil for a given child task (the terminal hook's lookup). O(n)
|
|
70
|
+
# scan — single-node, like PendingActionStore#open_for.
|
|
71
|
+
def find_by_child_task(child_task_id)
|
|
72
|
+
id = child_task_id.to_s
|
|
73
|
+
scan { |d| return d if d.child_task_id == id }
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# -> [Delegation] that are NOT delivered yet (boot recovery). completed-but-
|
|
78
|
+
# undelivered = a crash between capture and delivery; dispatched = the child
|
|
79
|
+
# may or may not be terminal (the caller checks the child task).
|
|
80
|
+
def undelivered
|
|
81
|
+
scan.reject { |d| d.status == :delivered }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# dispatched -> completed, capturing the child's result/error. Idempotent: a
|
|
85
|
+
# second call on an already-completed/delivered record is a no-op (returns the
|
|
86
|
+
# current record) — the terminal hook and recovery can race.
|
|
87
|
+
def mark_completed(id, result: nil, error: nil)
|
|
88
|
+
record = fetch!(id)
|
|
89
|
+
return to_delegation(record) unless record["status"] == "dispatched"
|
|
90
|
+
|
|
91
|
+
record["status"] = "completed"
|
|
92
|
+
record["result"] = result
|
|
93
|
+
record["error"] = error
|
|
94
|
+
touch(id, record)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# completed -> delivered, ATOMICALLY (the claim) — across processes, not just
|
|
98
|
+
# fibers: the read-check-write rides Store#transaction, so two workers racing
|
|
99
|
+
# the same record serialize on the backend's lock and only one sees
|
|
100
|
+
# :completed. Returns true only for the caller that won the transition — that
|
|
101
|
+
# caller (and only it) spawns the delivery turn, so delivery is at-most-once
|
|
102
|
+
# even if the hook and recovery both fire. A record not in :completed
|
|
103
|
+
# (already delivered, or still dispatched) -> false.
|
|
104
|
+
def claim_delivery(id)
|
|
105
|
+
@store.transaction do
|
|
106
|
+
record = @store.get(SCOPE, key_for(id))
|
|
107
|
+
next false unless record && record["status"] == "completed"
|
|
108
|
+
|
|
109
|
+
record["status"] = "delivered"
|
|
110
|
+
record["updated_at"] = timestamp
|
|
111
|
+
@store.set(SCOPE, key_for(id), record)
|
|
112
|
+
true
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def scan
|
|
119
|
+
return enum_for(:scan) unless block_given?
|
|
120
|
+
|
|
121
|
+
@store.list(SCOPE, KEY_PREFIX).each do |key|
|
|
122
|
+
record = @store.get(SCOPE, key)
|
|
123
|
+
yield to_delegation(record) if record
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def fetch!(id)
|
|
128
|
+
@store.get(SCOPE, key_for(id)) ||
|
|
129
|
+
(raise Insika::NotFoundError, "delegation not found: #{id}")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def touch(id, record)
|
|
133
|
+
record["updated_at"] = timestamp
|
|
134
|
+
@store.set(SCOPE, key_for(id), record)
|
|
135
|
+
to_delegation(record)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def key_for(id) = "#{KEY_PREFIX}#{id}"
|
|
139
|
+
|
|
140
|
+
def to_delegation(record)
|
|
141
|
+
Delegation.new(
|
|
142
|
+
id: record["id"], parent_task_id: record["parent_task_id"],
|
|
143
|
+
parent_session_id: record["parent_session_id"], parent_agent: record["parent_agent"],
|
|
144
|
+
child_agent: record["child_agent"], child_task_id: record["child_task_id"],
|
|
145
|
+
child_session_id: record["child_session_id"], depth: record["depth"],
|
|
146
|
+
status: record["status"].to_sym, result: record["result"], error: record["error"],
|
|
147
|
+
created_at: record["created_at"], updated_at: record["updated_at"]
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def timestamp = Time.now.utc.iso8601
|
|
152
|
+
end
|
|
153
|
+
end
|