insika 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +199 -5
- data/README.md +8 -2
- data/bin/insika +231 -13
- data/docs/AGENTS.md +505 -6
- data/docs/API.md +56 -0
- data/docs/CHANNELS.md +100 -10
- data/docs/CONTEXT.md +147 -19
- data/docs/DEPLOY.md +34 -11
- data/docs/EMBEDDING.md +11 -7
- data/docs/EVALS.md +20 -1
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +117 -0
- data/docs/LOADTEST.md +17 -10
- data/docs/OBSERVABILITY.md +65 -2
- data/docs/REFINEMENT.md +9 -9
- data/docs/RELEASING.md +34 -7
- data/docs/RUNNING-LOCAL.md +4 -4
- data/docs/SECURITY.md +85 -11
- data/docs/SKILLS.md +189 -3
- data/docs/SOAK.md +127 -0
- data/docs/TOOLS.md +70 -2
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/domain.md +115 -0
- data/docs/index.md +2 -2
- data/docs/onboarding/start.md +1 -1
- data/lib/insika/agent_profile.rb +228 -26
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/baseline_store.rb +2 -2
- data/lib/insika/budget_ledger.rb +166 -0
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +132 -24
- data/lib/insika/channel_registry.rb +1 -1
- data/lib/insika/channels/relay.rb +80 -6
- data/lib/insika/channels/web/widget.js +2 -2
- data/lib/insika/channels/web.rb +9 -9
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +145 -13
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +8 -0
- data/lib/insika/commands/agent_payload.rb +6 -4
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/create_agent.rb +2 -2
- data/lib/insika/commands/create_session.rb +1 -1
- data/lib/insika/commands/delete_llm_provider.rb +1 -1
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_tenant_data.rb +95 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +12 -12
- data/lib/insika/commands/import_mcp_tools.rb +1 -1
- data/lib/insika/commands/import_tools.rb +4 -4
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/resolve_refinement.rb +1 -1
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/run_refinement.rb +5 -5
- data/lib/insika/commands/send_message.rb +112 -15
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/set_agent_tools.rb +1 -1
- data/lib/insika/commands/set_skill_agents.rb +60 -19
- data/lib/insika/commands/trigger_workflow.rb +1 -1
- data/lib/insika/commands/update_agent.rb +1 -1
- data/lib/insika/commands/write_data_tool.rb +1 -1
- data/lib/insika/commands/write_golden.rb +1 -1
- data/lib/insika/commands/write_skill.rb +19 -9
- data/lib/insika/config_store.rb +8 -4
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +23 -5
- data/lib/insika/context/fragment.rb +31 -3
- data/lib/insika/context/priority.rb +6 -2
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +30 -2
- data/lib/insika/context/providers/request.rb +1 -1
- data/lib/insika/context/providers/session.rb +17 -2
- data/lib/insika/context/providers/skill.rb +7 -1
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +128 -0
- data/lib/insika/delegation_store.rb +2 -2
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +962 -7
- data/lib/insika/dsl/runtime.rb +20 -11
- data/lib/insika/dsl/server_boot.rb +74 -4
- data/lib/insika/dsl/system.rb +1 -1
- data/lib/insika/dsl.rb +152 -15
- data/lib/insika/edge_limiter.rb +167 -8
- data/lib/insika/egress_guard.rb +3 -3
- data/lib/insika/env_schema.rb +22 -12
- data/lib/insika/errors.rb +72 -5
- data/lib/insika/evals/assertions.rb +15 -14
- data/lib/insika/evals/baseline.rb +3 -3
- data/lib/insika/evals/golden.rb +8 -8
- data/lib/insika/evals/judge.rb +7 -7
- data/lib/insika/evals/pairwise.rb +21 -9
- data/lib/insika/evals/report.rb +2 -2
- data/lib/insika/evals/runner.rb +6 -6
- data/lib/insika/evals/transport.rb +2 -2
- data/lib/insika/event_stream.rb +23 -5
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +1092 -160
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +221 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/frontmatter.rb +1 -1
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +3 -3
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/http_client.rb +3 -3
- data/lib/insika/inbound_log.rb +1 -1
- data/lib/insika/llm_configurator.rb +3 -3
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +4 -4
- data/lib/insika/mcp_tool_ingestor.rb +6 -6
- data/lib/insika/media.rb +298 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +8 -3
- data/lib/insika/model_resolver.rb +1 -1
- data/lib/insika/model_selection.rb +5 -4
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +8 -3
- data/lib/insika/outbox_store.rb +44 -6
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +3 -4
- data/lib/insika/pack.rb +3 -3
- data/lib/insika/pack_importer.rb +17 -15
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/pending_action_store.rb +1 -1
- data/lib/insika/plugin/loader.rb +2 -2
- data/lib/insika/policy/policy.rb +1 -1
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +34 -7
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +6 -3
- data/lib/insika/recovery.rb +47 -6
- data/lib/insika/refinement/candidate.rb +4 -4
- data/lib/insika/refinement/evidence_collector.rb +6 -6
- data/lib/insika/refinement/gate.rb +7 -7
- data/lib/insika/refinement/panel.rb +7 -7
- data/lib/insika/refinement/proposer.rb +10 -10
- data/lib/insika/refinement_store.rb +12 -12
- data/lib/insika/reliability.rb +211 -0
- data/lib/insika/retention.rb +281 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +46 -6
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +34 -115
- data/lib/insika/safety/factory.rb +18 -5
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +20 -5
- data/lib/insika/safety/moderator.rb +19 -11
- data/lib/insika/safety/output_filter.rb +10 -6
- data/lib/insika/safety/output_validator.rb +13 -7
- data/lib/insika/safety/safe_responses.rb +1 -1
- data/lib/insika/sandbox/boundary.rb +2 -2
- data/lib/insika/sandbox.rb +1 -1
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +366 -54
- data/lib/insika/server/boot.rb +4 -4
- data/lib/insika/server/rack_app.rb +31 -7
- data/lib/insika/server/responses.rb +58 -9
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +11 -7
- data/lib/insika/session_store.rb +66 -3
- data/lib/insika/settings_store.rb +15 -5
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/shutdown.rb +4 -4
- data/lib/insika/skill_catalog.rb +131 -20
- data/lib/insika/skill_store.rb +70 -22
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +1 -1
- data/lib/insika/store.rb +11 -2
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1058 -75
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +27 -26
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +274 -22
- data/lib/insika/studio/nav_icons.rb +7 -2
- data/lib/insika/studio/views/_message.erb +2 -2
- data/lib/insika/studio/views/agent_detail.erb +629 -86
- data/lib/insika/studio/views/agents.erb +11 -7
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +4 -1
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +2 -1
- data/lib/insika/studio/views/layout.erb +1 -0
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/refinement.erb +4 -4
- data/lib/insika/studio/views/session.erb +133 -3
- data/lib/insika/studio/views/settings.erb +9 -12
- data/lib/insika/studio/views/skills.erb +66 -12
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +0 -1
- data/lib/insika/subagent_graph.rb +3 -3
- data/lib/insika/task_actor.rb +3 -3
- data/lib/insika/task_store.rb +22 -2
- data/lib/insika/telemetry/pricing.rb +3 -3
- data/lib/insika/telemetry/recorder.rb +1 -1
- data/lib/insika/telemetry.rb +2 -2
- data/lib/insika/testing/store_contract.rb +54 -33
- data/lib/insika/tick.rb +146 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +5 -5
- data/lib/insika/tool_definition.rb +25 -15
- data/lib/insika/tool_envelope.rb +70 -1
- data/lib/insika/tool_manifest.rb +11 -7
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_store.rb +1 -1
- data/lib/insika/tool_trace_store.rb +1 -1
- data/lib/insika/tools/concurrency.rb +2 -2
- data/lib/insika/tools/data_defined_tool.rb +14 -5
- data/lib/insika/tools/generate_image.rb +44 -0
- data/lib/insika/tools/load_skill.rb +61 -3
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +4 -4
- data/lib/insika/tools/subagents.rb +1 -1
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +2 -2
- data/lib/insika/turn_state.rb +54 -13
- data/lib/insika/turn_timing.rb +24 -4
- data/lib/insika/usage_ledger.rb +1 -1
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +372 -34
- data/lib/insika/workflow.rb +1 -1
- data/lib/insika/workflow_registry.rb +1 -1
- data/lib/insika.rb +122 -16
- metadata +95 -2
- data/lib/insika/server/admin_auth.rb +0 -29
data/docs/domain.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: The domain-free core
|
|
3
|
+
parent: Operate & prove it
|
|
4
|
+
nav_order: 9
|
|
5
|
+
permalink: /domain/
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# The domain-free core — what ships, what a deployment declares, and how to clear it
|
|
9
|
+
|
|
10
|
+
The engine is domain-free by construction (RFC-0036): the gem carries no store
|
|
11
|
+
vocabulary, no persona, and no fixed conversation language. This page is the
|
|
12
|
+
removability map — for every artifact that could make a deployment look like
|
|
13
|
+
"the Brazilian store harness", here is what ships, what the doctor reports, and
|
|
14
|
+
how to clear it.
|
|
15
|
+
|
|
16
|
+
## What ships and what does not
|
|
17
|
+
|
|
18
|
+
One selection function owns the gem payload: `Insika::Packaging.payload_files`
|
|
19
|
+
— the gemspec and the audit spec (`spec/insika/domain_boundary_spec.rb`) call
|
|
20
|
+
the same function, so the boundary is a fact the suite asserts on, never a
|
|
21
|
+
prose promise. It ships:
|
|
22
|
+
|
|
23
|
+
- `lib/` — the engine, the server, the Studio (the compiled JS only);
|
|
24
|
+
- `docs/` — the public documentation;
|
|
25
|
+
- `README.md`, `LICENSE`, `CHANGELOG.md`, `bin/insika`.
|
|
26
|
+
|
|
27
|
+
It never ships: `deploy/`, `packs/`, `examples/`, `plugins/`, `evals/`,
|
|
28
|
+
`scripts/`, `spec/` — even a tracked pack cannot ship. Packs, personas and
|
|
29
|
+
deployment wiring are checkout material, and that is the point: the reference
|
|
30
|
+
deployment is not the gem.
|
|
31
|
+
|
|
32
|
+
The proof command is `insika doctor --domain`: a read-only inventory of what a
|
|
33
|
+
deployment declares, plus the built-in corpora still in effect. A bare install
|
|
34
|
+
with no agents names nothing; an install that boots an agent reports the
|
|
35
|
+
built-in pt-BR guardrail corpus as `source: gem-default` — the removability
|
|
36
|
+
surface, not a store. The section never fails the doctor's exit code: it is
|
|
37
|
+
informational, the RFC's proof surface.
|
|
38
|
+
|
|
39
|
+
## The guardrail corpus — clear the shipped pt-BR content
|
|
40
|
+
|
|
41
|
+
The deterministic guardrail patterns ship as language-tagged data
|
|
42
|
+
(`Safety::Corpus`). A deployment clears a language by config, per agent:
|
|
43
|
+
|
|
44
|
+
```jsonc
|
|
45
|
+
// pack: agent.config.json
|
|
46
|
+
{ "guardrails": { "corpora": { "languages": ["en"] } } }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# DSL
|
|
51
|
+
agent = Insika.agent("store-support") do
|
|
52
|
+
instructions "…"
|
|
53
|
+
guardrails corpora: { languages: ["en"] }
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- `languages` filters the shipped families: `nil` = all (the default),
|
|
58
|
+
`[]` = none, `["en"]` = the EN-only corpus. An EN-only deployment drops the
|
|
59
|
+
pt-BR input heuristics **and** the CPF/CNPJ output redaction — a documented
|
|
60
|
+
consequence: the tax-id formats are pt-BR data, and the universal "secret"
|
|
61
|
+
redaction is never cleared.
|
|
62
|
+
- `extra` adds source-string patterns per family:
|
|
63
|
+
|
|
64
|
+
```jsonc
|
|
65
|
+
{ "guardrails": { "corpora": { "extra": { "abuse": ["/\\bdupa\\b/i"] } } } }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- `guardrails.responses` replaces the built-in pt-BR fallback replies (the
|
|
69
|
+
safe refusals). Set a `default` to clear every category at once.
|
|
70
|
+
|
|
71
|
+
See [Security](SECURITY.md) for the guardrail layers; the doctor's domain
|
|
72
|
+
section enumerates any agent still running the built-in pt-BR corpus or the
|
|
73
|
+
built-in replies, with the clear path in each entry. An unknown language or
|
|
74
|
+
family, or a malformed pattern, is refused at boot — `insika doctor` reports it
|
|
75
|
+
as an error and the agent never turns with a broken corpus.
|
|
76
|
+
|
|
77
|
+
## Marking a deployment — declare, never infer
|
|
78
|
+
|
|
79
|
+
The engine never guesses a store. Domain markers are data a deployment
|
|
80
|
+
declares, and the doctor reads only those declarations:
|
|
81
|
+
|
|
82
|
+
- **Personas/packs** — `metadata.domain` on the agent profile:
|
|
83
|
+
|
|
84
|
+
```jsonc
|
|
85
|
+
{ "metadata": { "domain": "e-commerce-pt-BR" } }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
metadata domain: "e-commerce-pt-BR"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- **Outcome funnel** — `funnel:` on the profile (see
|
|
93
|
+
[Outcomes](AGENTS.md#outcomes--business-results-over-real-traffic-ws7)).
|
|
94
|
+
Vocabulary note: in the gem and the doctor output it is an **outcome
|
|
95
|
+
funnel**, never "conversion" — the stage names are the deployment's, and a
|
|
96
|
+
bare install shows no funnel and no stage names at all.
|
|
97
|
+
- **Evidence** — the `evidence:` declaration on a tool manifest (see
|
|
98
|
+
[Tools](TOOLS.md)); the kinds are the deployment's vocabulary, never gem
|
|
99
|
+
constants.
|
|
100
|
+
|
|
101
|
+
`insika doctor --domain` enumerates all four with their source —
|
|
102
|
+
`deployment` for declared data, `gem-default` for the built-in corpus still in
|
|
103
|
+
effect — and a bare boot names none.
|
|
104
|
+
|
|
105
|
+
## The conformance claim — model-visible means logged
|
|
106
|
+
|
|
107
|
+
"Every byte that reaches the provider is reconstructable from checkpoints +
|
|
108
|
+
traces" is a spec, not a promise. For each (task, turn) the engine records the
|
|
109
|
+
model-visible payload at the provider boundary — the system text, the tool
|
|
110
|
+
schemas, and the full message stream (`ModelVisibleTraceStore`) — next to the
|
|
111
|
+
durable transcript (the checkpoint). The conformance suite
|
|
112
|
+
(`spec/insika/conformance/model_visible_spec.rb`) drives real turns on a
|
|
113
|
+
capturing chat and asserts a three-way byte identity: what the chat held ==
|
|
114
|
+
the checkpoint transcript == the model trace. A path whose bytes are not
|
|
115
|
+
logged is a fix in the engine, never a waiver in the suite.
|
data/docs/index.md
CHANGED
|
@@ -34,9 +34,9 @@ Ruby `>= 3.3` and a provider key (the demo uses DeepSeek). The whole program:
|
|
|
34
34
|
require "insika"
|
|
35
35
|
|
|
36
36
|
assistant = Insika.agent("assistant") do
|
|
37
|
-
model "deepseek-
|
|
37
|
+
model "deepseek-v4-flash"
|
|
38
38
|
provider :deepseek
|
|
39
|
-
instructions "You are
|
|
39
|
+
instructions "You are a concise and friendly assistant. Answer briefly."
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
puts assistant.reply("hi, what can you do?") # one turn, in-process
|
data/docs/onboarding/start.md
CHANGED
|
@@ -51,7 +51,7 @@ require "insika"
|
|
|
51
51
|
|
|
52
52
|
assistant = Insika.agent("assistant") do
|
|
53
53
|
provider :deepseek # ← the provider slug from {{MODELS_URL}}
|
|
54
|
-
model "deepseek-
|
|
54
|
+
model "deepseek-v4-flash" # ← a model id from {{MODELS_URL}}
|
|
55
55
|
instructions "You are a concise, friendly assistant. Answer briefly."
|
|
56
56
|
end
|
|
57
57
|
|
data/lib/insika/agent_profile.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "coercion"
|
|
4
|
+
require_relative "tool_definition"
|
|
4
5
|
|
|
5
6
|
module Insika
|
|
6
7
|
# Single point of per-agent policy.
|
|
@@ -16,11 +17,24 @@ module Insika
|
|
|
16
17
|
:id, :model, :provider,
|
|
17
18
|
:base_prompt, :prompt_files,
|
|
18
19
|
:tools_allow, :tools_deny,
|
|
19
|
-
:tools_allow_groups, # per-GROUP allowlist
|
|
20
|
+
:tools_allow_groups, # per-GROUP allowlist:
|
|
20
21
|
# union with tools_allow; deny wins; both
|
|
21
22
|
# nil = all (parity). Expands to the group's
|
|
22
23
|
# tools in the ToolAllowlist policy.
|
|
23
24
|
:skills,
|
|
25
|
+
:skills_eager, # progressive disclosure OFF, wholly or in part:
|
|
26
|
+
# nil/false = level 1 + load_skill (parity); true = every
|
|
27
|
+
# allowed skill; [names] = exactly these. An eager skill's
|
|
28
|
+
# BODY enters the prompt each turn, it leaves the
|
|
29
|
+
# <available_skills> catalog and load_skill refuses it.
|
|
30
|
+
# Removes the activation DECISION (no miss rate) at the
|
|
31
|
+
# cost of the bodies' tokens — measure them against
|
|
32
|
+
# context_budget before turning it on. Same opt-in as
|
|
33
|
+
# `memory`. It lives HERE and not in the SKILL.md
|
|
34
|
+
# frontmatter because skills are shared between agents:
|
|
35
|
+
# a per-skill flag forced one decision onto every
|
|
36
|
+
# allowlist holding the skill. NOT `Allowlist`
|
|
37
|
+
# semantics — nil means NONE here (SkillCatalog#eager_for).
|
|
24
38
|
:context_providers, # provider allowlist
|
|
25
39
|
:workflows_allow, # applied by WorkflowAllowlist
|
|
26
40
|
:policies, # names in the Policy Registry
|
|
@@ -30,7 +44,7 @@ module Insika
|
|
|
30
44
|
:capabilities, # intents the agent can trigger.
|
|
31
45
|
# nil = NONE (opt-in, see above).
|
|
32
46
|
:subagents, # allowlist of child agent ids this agent MAY spawn
|
|
33
|
-
#
|
|
47
|
+
# CAPACITY field — NEVER inherits;
|
|
34
48
|
# opt-in like `capabilities`: nil/absent = NONE (do NOT
|
|
35
49
|
# "fix" to nil = all). Present => the `spawn_subagent`
|
|
36
50
|
# system tool is wired (ChatBuilder), gated by this set.
|
|
@@ -40,7 +54,7 @@ module Insika
|
|
|
40
54
|
:memory, # cross-session memory.
|
|
41
55
|
# nil/false = OFF (parity: provider []; the `remember`
|
|
42
56
|
# tool not wired); true = ON. Same opt-in as capabilities.
|
|
43
|
-
:prompt_caching, # Anthropic prompt caching (
|
|
57
|
+
:prompt_caching, # Anthropic prompt caching (R3): nil/false = OFF
|
|
44
58
|
# (parity); true = ON. Same opt-in as `memory`. When ON
|
|
45
59
|
# AND the resolved provider is Anthropic, ChatBuilder sets
|
|
46
60
|
# ONE cache breakpoint at the end of the system block
|
|
@@ -50,34 +64,108 @@ module Insika
|
|
|
50
64
|
# a context provider injecting volatile content into
|
|
51
65
|
# :system turns every turn into a paid cache WRITE with
|
|
52
66
|
# no read hit. Enable only for stable-system agents.
|
|
53
|
-
:
|
|
67
|
+
:tool_persistence, # the engine's "Tool discipline" block in the system
|
|
68
|
+
# prompt (retry weak/empty tool results with a different
|
|
69
|
+
# approach before giving up). THE ONE OPT-OUT FIELD:
|
|
70
|
+
# nil/true = ON (the proven default — every reference
|
|
71
|
+
# harness ships it), false = OFF. Deliberately inverted
|
|
72
|
+
# from the opt-in fields above: the exception here is
|
|
73
|
+
# turning the good behavior OFF, so that is what an
|
|
74
|
+
# operator declares. Read by Context::Providers::Prompt.
|
|
75
|
+
:tool_output_compression, # MECHANICAL tool-result dedupe in the replayed
|
|
76
|
+
# history (A3/C3): nil/false = OFF (parity); true = ON.
|
|
77
|
+
# Same opt-in as `memory`. When ON, the history the
|
|
78
|
+
# Session provider seeds replaces byte-identical repeated
|
|
79
|
+
# tool results with a compact back-reference (first
|
|
80
|
+
# occurrence stays full) — no LLM involved. CHANGES WHAT
|
|
81
|
+
# THE MODEL SEES: an older full result is only the first
|
|
82
|
+
# occurrence; a model that wants an older detail re-calls
|
|
83
|
+
# the tool. Cheap half of compaction for bloated histories.
|
|
84
|
+
:params, # LLM generation params: a Hash with
|
|
54
85
|
# temperature/max_tokens/thinking, applied to the chat at
|
|
55
86
|
# stage 5. {} = provider defaults (parity).
|
|
56
|
-
:
|
|
87
|
+
:budget, # spend caps per (tenant, agent) over
|
|
88
|
+
# CALENDAR windows (WS2): { "daily" => int,
|
|
89
|
+
# "monthly" => int, "soft" => bool, "alert_at" => 0.8 }.
|
|
90
|
+
# HARD is the default: absent/"soft": false, a turn
|
|
91
|
+
# arriving at/over the cap fails with Insika::BudgetExceeded
|
|
92
|
+
# and the envelope quotes `budget_exceeded` + retry_after.
|
|
93
|
+
# "soft": true crosses the cap and still runs
|
|
94
|
+
# (one budget_warning event per window + a note in the
|
|
95
|
+
# context).
|
|
96
|
+
# Tokens count the billed spend (input+output+cached+
|
|
97
|
+
# cache_creation). nil/absent = no budget (parity).
|
|
98
|
+
:reliability, # the provider-interaction reliability policy (WS3):
|
|
99
|
+
# { "retries" => 3, "backoff" => "exponential",
|
|
100
|
+
# "fallback" => ["gpt-4o-mini", ...],
|
|
101
|
+
# "circuit_breaker" => { "after" => 10, "within" => 60,
|
|
102
|
+
# "cooldown" => 300 }, "timeout" => 30 }. Data, never
|
|
103
|
+
# DSL: retries + exponential backoff on :retryable /
|
|
104
|
+
# :rate_limited_* failures (never :fatal), mid-turn
|
|
105
|
+
# rotation across the fallback chain (profile's first,
|
|
106
|
+
# then the platform's resolved fallbacks), and a circuit
|
|
107
|
+
# breaker per (tenant, provider/model) that fail-fasts
|
|
108
|
+
# with circuit_open + retry_after once the window count
|
|
109
|
+
# trips. nil/absent = the plain single attempt (parity).
|
|
110
|
+
:alerts, # operator alert delivery (WS6): { "webhook" => url }.
|
|
111
|
+
# When present, the agent's budget_warning /
|
|
112
|
+
# breaker_open / delivery_failed events are POSTed to
|
|
113
|
+
# the URL as JSON (outbox + claim, at-most-once).
|
|
114
|
+
# nil/absent = no webhook (parity).
|
|
115
|
+
:routes, # intent routing (WS4): { "route" => "description" or a
|
|
116
|
+
# Hash with description/delegate/stuck/message,
|
|
117
|
+
# "default" => route, "model" => cheap classifier }.
|
|
118
|
+
# When present, the message is classified BEFORE the
|
|
119
|
+
# ask with the cheap model; the route lands on the
|
|
120
|
+
# turn (state.route + :route_classified) and may
|
|
121
|
+
# delegate to an existing agent or end the turn :stuck
|
|
122
|
+
# (WS5). nil/absent = no routing (parity).
|
|
123
|
+
:stuck_signal, # the agent may signal it cannot proceed (WS5):
|
|
124
|
+
# nil/false = OFF (parity — the signal_stuck system
|
|
125
|
+
# tool is not wired); true = ON (the model may call
|
|
126
|
+
# signal_stuck, which ends the turn with
|
|
127
|
+
# `outcome: :stuck` + a final message + a :turn_stuck
|
|
128
|
+
# event the consumer acts on). Same opt-in as
|
|
129
|
+
# `memory`. What "stuck" MEANS is the consumer's call
|
|
130
|
+
# (escalation via CRM/operator), never the engine's.
|
|
131
|
+
:outputs, # generated-media output policy (WS9, saída):
|
|
132
|
+
# { "image" => { "model" => …, "size" => "1024x1024" },
|
|
133
|
+
# "tts" => { "model" => "tts-1", "voice" => "alloy",
|
|
134
|
+
# "format" => "mp3" } }. THE AGENT'S HALF of the
|
|
135
|
+
# media-output gate — nil/absent = the agent never
|
|
136
|
+
# generates media (opt-in like `capabilities`, do NOT
|
|
137
|
+
# "fix" to nil = all). The other half is the CHANNEL'S:
|
|
138
|
+
# the request must declare it can receive the media
|
|
139
|
+
# (`channel.capabilities` — "image_output" /
|
|
140
|
+
# "audio_output"); only with BOTH does the model see
|
|
141
|
+
# the generate_image/tts tools (the abstraction admits
|
|
142
|
+
# only what leaks). Generated media rides the turn's
|
|
143
|
+
# `output_parts` in the envelope, never the answer text.
|
|
144
|
+
:model_policy, # governance of WHICH models the agent may use:
|
|
57
145
|
# { "allow" => [refs] }. nil = NO fence (all models —
|
|
58
146
|
# parity). Enforced on the RESOLVED model (ModelResolver).
|
|
59
|
-
:guardrails, # content-safety config
|
|
147
|
+
:guardrails, # content-safety config: { input:, output:,
|
|
60
148
|
# moderator:, strictness: }. OPT-IN like capabilities —
|
|
61
149
|
# nil/absent = the conservative default (Safety::Config:
|
|
62
150
|
# deterministic on, moderator off). Parsed, never a policy.
|
|
63
|
-
:sandbox, # confined-execution config
|
|
151
|
+
:sandbox, # confined-execution config:
|
|
64
152
|
# { provider: "local"|"docker", root:, timeout:, ...+provider
|
|
65
153
|
# keys }. Declarative provider selection (config-over-code) —
|
|
66
154
|
# consumed by Insika::Sandbox.build. {} = absent (a
|
|
67
155
|
# deployment builds a `local` sandbox by default). It is
|
|
68
156
|
# CONFIG, never a policy — it does not decide security by
|
|
69
157
|
# itself; the FS boundary + approvals do.
|
|
70
|
-
:refinement, # self-improvement config
|
|
158
|
+
:refinement, # self-improvement config:
|
|
71
159
|
# { mode: "report"|"propose"|"auto_apply", window: {…},
|
|
72
160
|
# files: [allowlist], proposers: [refs], budget: {tokens:},
|
|
73
161
|
# auto_apply_max_edits:, max_findings:, … }. nil/absent =
|
|
74
|
-
# REPORT-ONLY (
|
|
162
|
+
# REPORT-ONLY (writes nothing to the agent, so
|
|
75
163
|
# reading your own traces needs no opt-in); `propose`
|
|
76
164
|
# and above must be enabled explicitly. It is CONFIG,
|
|
77
165
|
# never a policy — the write allowlist it carries is
|
|
78
166
|
# enforced by the applier, not by this field.
|
|
79
167
|
:capabilities_declared, # FACTS ABOUT THIS DEPLOYMENT that are not tools
|
|
80
|
-
#
|
|
168
|
+
# %w[promotions human_handoff
|
|
81
169
|
# b2b_pricing]. An eval case declares what it
|
|
82
170
|
# `requires` and is SKIPPED — never failed — where
|
|
83
171
|
# the deployment lacks it, which is what makes one
|
|
@@ -104,10 +192,72 @@ module Insika
|
|
|
104
192
|
# (WhatsApp) puts the deliberation in front of a
|
|
105
193
|
# customer; that is the operator's call to make, not a
|
|
106
194
|
# default to inherit.
|
|
107
|
-
:
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
195
|
+
:grounding, # the pack's grounding policy —
|
|
196
|
+
# { "mode" => "flag"|"enforce"|"off",
|
|
197
|
+
# "matcher" => { "sku" => …,
|
|
198
|
+
# "name_keys" => [...] } }. OPT-IN:
|
|
199
|
+
# nil/absent = OFF (parity, zero allocations).
|
|
200
|
+
# Deep-stringified like the other hashes.
|
|
201
|
+
:metadata, # free-form agent metadata, stable per agent
|
|
202
|
+
# (from the pack `agent.config.json`). Home of the `store_id`
|
|
203
|
+
# that becomes turn context (ctx.store_id).
|
|
204
|
+
# It is NOT a policy — never decides security. {} = absent.
|
|
205
|
+
:briefing_fields, # the per-session working-state schema this agent
|
|
206
|
+
# keeps and asks for: a flat [String] of
|
|
207
|
+
# field names the pack declares. []/nil/absent = the
|
|
208
|
+
# feature is OFF (no provider output, no tools — visibly
|
|
209
|
+
# removable). Names are engine-owned store keys
|
|
210
|
+
# and tool text, so they are validated against NAME_RE at
|
|
211
|
+
# build time. Data, never a policy: the engine owns the
|
|
212
|
+
# briefing object, the pack owns the fields.
|
|
213
|
+
:funnel, # the outcome funnel declaration — pack
|
|
214
|
+
# data, exactly like budget/reliability:
|
|
215
|
+
# { "stages" => ["greeted", "qualified", "cart", "paid"],
|
|
216
|
+
# "advance_on" => { "pix_paid" => "paid", … },
|
|
217
|
+
# "primary" => "paid", "attribution_window" => "72h" }.
|
|
218
|
+
# The ENGINE never hard-codes a stage name: the fold,
|
|
219
|
+
# the doctor and the Studio read this declaration (D1).
|
|
220
|
+
# nil/absent = no funnel (parity — nothing folds).
|
|
221
|
+
# Deep-stringified like the other free-form hashes;
|
|
222
|
+
# shape-validated by FunnelDeclaration, never here (D8).
|
|
223
|
+
:followup, # the follow-up declaration — pack data,
|
|
224
|
+
# exactly like budget/funnel:
|
|
225
|
+
# { "arm" => "schedule",
|
|
226
|
+
# "policy" => { "quiet_hours" => { "timezone" => "…",
|
|
227
|
+
# "start" => "21:30", "end" => "09:00" },
|
|
228
|
+
# "max_frequency" => "2/24h",
|
|
229
|
+
# "cancel_keywords" => ["não quero mais contato"],
|
|
230
|
+
# "silence_after_sends" => 3 } }.
|
|
231
|
+
# The engine OWNS the firing, never a policy value (D1);
|
|
232
|
+
# shape-validated by FollowupPolicy, never here (D9).
|
|
233
|
+
# nil/absent = the feature is off (parity).
|
|
234
|
+
# Deep-stringified like the other free-form hashes.
|
|
235
|
+
:distill, # the session-distillation declaration — pack
|
|
236
|
+
# data, exactly like refinement/followup:
|
|
237
|
+
# { "enabled" => bool, "prompt" => "<pack-authored markdown
|
|
238
|
+
# — what counts as a fact for this store>",
|
|
239
|
+
# "model" => "<ref — absent = the platform utility_model>",
|
|
240
|
+
# "idle_hours" => 6, "min_messages" => 3,
|
|
241
|
+
# "max_proposals" => 10 }.
|
|
242
|
+
# The ENGINE assembles the scope from the session; the
|
|
243
|
+
# model only names facts (D1). nil/absent = the feature is
|
|
244
|
+
# off (parity, byte-identical engine). Shape-validated by
|
|
245
|
+
# the command/engine, never here (the refinement precedent).
|
|
246
|
+
# Deep-stringified like the other free-form hashes.
|
|
247
|
+
:harvest # the gated-harvest declaration — pack data,
|
|
248
|
+
# exactly like refinement/distill:
|
|
249
|
+
# { "enabled" => bool,
|
|
250
|
+
# "negative_list" => [ { "rule" => "…", "pattern" => "…",
|
|
251
|
+
# "note" => "…" } ],
|
|
252
|
+
# "miner" => { "model" => "<ref — absent = the platform
|
|
253
|
+
# utility_model>", "window" => { "last_sessions" => N },
|
|
254
|
+
# "max_proposals" => N, "budget" => { "tokens" => N } },
|
|
255
|
+
# "idle_hours" => 24, "min_messages" => 3 }.
|
|
256
|
+
# The ENGINE mines (reads sessions, asks the miner, filters
|
|
257
|
+
# through the negative list + grounding), never authors a
|
|
258
|
+
# rule (D4). nil/absent = the loop is off (parity).
|
|
259
|
+
# Shape-validated by the command/engine/doctor, never here.
|
|
260
|
+
# Deep-stringified like the other free-form hashes.
|
|
111
261
|
)
|
|
112
262
|
|
|
113
263
|
# Reopened class (not a Data.define block): a constant assigned inside
|
|
@@ -116,29 +266,36 @@ module Insika
|
|
|
116
266
|
DEFAULT_LIMITS = {
|
|
117
267
|
turn_timeout: 300, tool_timeout: 60, provider_timeout: 5,
|
|
118
268
|
context_budget: 8_000, max_tool_calls: 50,
|
|
269
|
+
# consecutive identical (tool, args) calls that trigger the ONE
|
|
270
|
+
# loop warning; a repeat after it aborts like max_tool_calls. < 2 = off.
|
|
271
|
+
max_tool_repeat: 3,
|
|
119
272
|
approval_timeout: 3_600, # cap on the wait for human approval (~1h)
|
|
120
|
-
#
|
|
273
|
+
# parallel tool calls. ONE number is both the switch and the cap
|
|
121
274
|
# (nil/0/1 = serial, the default; N > 1 = at most N tool calls in flight).
|
|
122
275
|
# It sits next to tool_timeout/max_tool_calls because it is the third bound
|
|
123
276
|
# on tool execution. Read through TurnState#tool_concurrency, which also
|
|
124
|
-
# applies the approval gate
|
|
277
|
+
# applies the approval gate.
|
|
125
278
|
tool_concurrency: 1
|
|
126
279
|
}.freeze
|
|
127
280
|
|
|
128
|
-
# `model` is OPTIONAL as of v2
|
|
281
|
+
# `model` is OPTIONAL as of v2: an agent without one resolves the
|
|
129
282
|
# platform `default_model` (Settings) at turn start via the ModelResolver.
|
|
130
283
|
def self.build(id:, model: nil, provider: nil, base_prompt: "", prompt_files: [],
|
|
131
284
|
tools_allow: nil, tools_deny: [], tools_allow_groups: nil, skills: nil,
|
|
132
|
-
context_providers: nil, workflows_allow: nil,
|
|
285
|
+
skills_eager: nil, context_providers: nil, workflows_allow: nil,
|
|
133
286
|
policies: [], prompt_refs: [], limits: {}, approvals_required: nil,
|
|
134
287
|
capabilities: nil, subagents: nil, tools_deferred: nil, memory: nil,
|
|
135
|
-
prompt_caching: nil,
|
|
136
|
-
|
|
137
|
-
|
|
288
|
+
prompt_caching: nil, tool_persistence: nil, tool_output_compression: nil,
|
|
289
|
+
params: {}, model_policy: nil, guardrails: nil, sandbox: nil,
|
|
290
|
+
refinement: nil, capabilities_declared: nil, edge_stream: nil, metadata: {},
|
|
291
|
+
budget: nil, reliability: nil, alerts: nil, routes: nil, stuck_signal: nil,
|
|
292
|
+
outputs: nil, briefing_fields: nil, grounding: nil, funnel: nil,
|
|
293
|
+
followup: nil, distill: nil, harvest: nil)
|
|
138
294
|
new(
|
|
139
295
|
id: id, model: model, provider: provider, base_prompt: base_prompt,
|
|
140
296
|
prompt_files: Array(prompt_files), tools_allow: tools_allow,
|
|
141
297
|
tools_deny: Array(tools_deny), tools_allow_groups: tools_allow_groups, skills: skills,
|
|
298
|
+
skills_eager: skills_eager,
|
|
142
299
|
context_providers: context_providers, workflows_allow: workflows_allow,
|
|
143
300
|
policies: Array(policies), prompt_refs: Array(prompt_refs),
|
|
144
301
|
limits: DEFAULT_LIMITS.merge(limits), approvals_required: approvals_required,
|
|
@@ -147,7 +304,8 @@ module Insika
|
|
|
147
304
|
# readers get a clean [] and the ChatBuilder gate (present? => wire) is stable.
|
|
148
305
|
subagents: subagents.nil? ? nil : Array(subagents).map(&:to_s),
|
|
149
306
|
tools_deferred: tools_deferred, memory: memory,
|
|
150
|
-
prompt_caching: prompt_caching,
|
|
307
|
+
prompt_caching: prompt_caching, tool_persistence: tool_persistence,
|
|
308
|
+
tool_output_compression: tool_output_compression,
|
|
151
309
|
# The free-form hashes arrive with symbol keys (internal build) OR string
|
|
152
310
|
# keys (StoredProfileSource JSON round-trip). Normalize to string keys ONCE
|
|
153
311
|
# here — the single front door every profile passes through — so no reader
|
|
@@ -161,10 +319,54 @@ module Insika
|
|
|
161
319
|
# symbol/string mix there would be a silent miss.
|
|
162
320
|
capabilities_declared: Array(capabilities_declared).map(&:to_s),
|
|
163
321
|
edge_stream: Coercion.deep_stringify(edge_stream || {}),
|
|
164
|
-
metadata: Coercion.deep_stringify(metadata || {})
|
|
322
|
+
metadata: Coercion.deep_stringify(metadata || {}),
|
|
323
|
+
budget: Coercion.deep_stringify(budget),
|
|
324
|
+
reliability: Coercion.deep_stringify(reliability),
|
|
325
|
+
alerts: Coercion.deep_stringify(alerts),
|
|
326
|
+
routes: Coercion.deep_stringify(routes),
|
|
327
|
+
stuck_signal: stuck_signal,
|
|
328
|
+
outputs: Coercion.deep_stringify(outputs),
|
|
329
|
+
# Flat [String] — same discipline as capabilities_declared: a
|
|
330
|
+
# symbol/string mix would be a silent miss in the provider's known-set.
|
|
331
|
+
briefing_fields: normalize_briefing_fields(briefing_fields),
|
|
332
|
+
# grounding is profile DATA, deep-stringified like the other
|
|
333
|
+
# free-form hashes; parsed into a Grounding per turn by the validator/
|
|
334
|
+
# enforcer. nil = off (parity).
|
|
335
|
+
grounding: Coercion.deep_stringify(grounding),
|
|
336
|
+
# funnel is profile DATA, deep-stringified like the other
|
|
337
|
+
# free-form hashes; parsed into a FunnelDeclaration by the fold/doctor/
|
|
338
|
+
# Studio (shape-validated THERE, never here — D8). nil = no funnel (parity).
|
|
339
|
+
funnel: Coercion.deep_stringify(funnel),
|
|
340
|
+
# followup is profile DATA, deep-stringified like the other
|
|
341
|
+
# free-form hashes; parsed into a FollowupPolicy by the tool/engine/
|
|
342
|
+
# doctor/Studio (shape-validated THERE, never here — D9). nil = off (parity).
|
|
343
|
+
followup: Coercion.deep_stringify(followup),
|
|
344
|
+
# distill is profile DATA, deep-stringified like the other
|
|
345
|
+
# free-form hashes; shape-validated by the command/engine/doctor
|
|
346
|
+
# (never here — the refinement precedent). nil = off (parity).
|
|
347
|
+
distill: Coercion.deep_stringify(distill),
|
|
348
|
+
# harvest is profile DATA, deep-stringified like the other
|
|
349
|
+
# free-form hashes; shape-validated by the command/engine/doctor
|
|
350
|
+
# (never here — the refinement precedent). nil = off (parity).
|
|
351
|
+
harvest: Coercion.deep_stringify(harvest)
|
|
165
352
|
)
|
|
166
353
|
end
|
|
167
354
|
|
|
355
|
+
# nil -> []; strings; trim + drop empties + uniq (stable order); every name
|
|
356
|
+
# must match ToolDefinition::NAME_RE (\A[a-z][a-z0-9_]*\z) or it is a
|
|
357
|
+
# ValidationError at build time — the names become tool-description text,
|
|
358
|
+
# store keys and context-block lines, so "size ok" or "tamanho do cliente"
|
|
359
|
+
# is refused here, not corrupted later.
|
|
360
|
+
def self.normalize_briefing_fields(list)
|
|
361
|
+
names = Array(list).map { |f| f.to_s.strip }.reject(&:empty?).uniq
|
|
362
|
+
bad = names.reject { |n| ToolDefinition::NAME_RE.match?(n) }
|
|
363
|
+
unless bad.empty?
|
|
364
|
+
raise Insika::ValidationError,
|
|
365
|
+
"briefing_fields must match #{ToolDefinition::NAME_RE.inspect}: #{bad.join(', ')}"
|
|
366
|
+
end
|
|
367
|
+
names
|
|
368
|
+
end
|
|
369
|
+
|
|
168
370
|
# opt-in for an optional tool = being in the agent's allow list.
|
|
169
371
|
def tool_opted_in?(name)
|
|
170
372
|
Array(tools_allow).include?(name)
|
|
@@ -173,8 +375,8 @@ module Insika
|
|
|
173
375
|
# store_id of the turn context (ctx.store_id): lives in `metadata` (stable
|
|
174
376
|
# per store, comes from the pack). `build` string-keys metadata, so a plain
|
|
175
377
|
# string lookup is enough. nil = absent (the data-tool emits an empty header).
|
|
176
|
-
# It is NOT
|
|
177
|
-
#
|
|
378
|
+
# It is NOT consumer-specific: `store_id` is a field of the turn-context contract
|
|
379
|
+
# generic per project.
|
|
178
380
|
def store_id = (metadata || {})["store_id"]
|
|
179
381
|
|
|
180
382
|
# May this channel (:thinking / :intermediate) cross to the customer? Tolerant
|
|
@@ -182,7 +384,7 @@ module Insika
|
|
|
182
384
|
# of anything else being absent: the safe reading is the default one.
|
|
183
385
|
def stream_public?(channel)
|
|
184
386
|
v = (edge_stream || {})[channel.to_s]
|
|
185
|
-
|
|
387
|
+
Coercion.truthy?(v)
|
|
186
388
|
end
|
|
187
389
|
end
|
|
188
390
|
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Operator alerts to a webhook (WS6): the events `:budget_warning`,
|
|
8
|
+
# `:breaker_open` and `:delivery_failed` are answered per AGENT — a profile
|
|
9
|
+
# declaring `alerts: { "webhook" => url }` gets its alerts POSTed there as
|
|
10
|
+
# JSON. The delivery reuses the outbox + claim mechanism whole
|
|
11
|
+
# (`ChannelDelivery`): the handler only WRITES the outbox row; the existing
|
|
12
|
+
# tick sweep and boot recovery claim and POST it, at-most-once with bounded
|
|
13
|
+
# retry, via a registered `Channels::Webhook`. The engine transports the event
|
|
14
|
+
# and does not interpret it — a Slack/CRM adapter is the consumer's.
|
|
15
|
+
#
|
|
16
|
+
# Started as a child of the turn supervisor (like the tick) in serving mode;
|
|
17
|
+
# tests drive `handle` directly.
|
|
18
|
+
class AlertDispatcher
|
|
19
|
+
ALERT_TYPES = %i[budget_warning breaker_open delivery_failed].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(event_stream:, outbox:, channels:, profiles:, task_store: nil, http:)
|
|
22
|
+
@event_stream = event_stream
|
|
23
|
+
@outbox = outbox
|
|
24
|
+
@channels = channels
|
|
25
|
+
@profiles = profiles
|
|
26
|
+
@task_store = task_store
|
|
27
|
+
@http = http
|
|
28
|
+
@webhook_ids = {} # url -> registered channel id (one webhook per URL)
|
|
29
|
+
# WS6 (boot recovery): webhook channels are derived from PROFILE config,
|
|
30
|
+
# not from events. Registering lazily (on the first alert) means a pending
|
|
31
|
+
# outbox row a crashed process left is swept at boot against an EMPTY
|
|
32
|
+
# registry and marked failed terminal. Pre-registering every configured
|
|
33
|
+
# URL at wiring time lets the boot sweep find the channel and deliver.
|
|
34
|
+
register_all_webhooks
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Serving: a long-lived consumer that answers every alert event. Drains on
|
|
38
|
+
# the supervisor fiber (blocks on the queue — no spin), exactly like the tick.
|
|
39
|
+
# It subscribes TYPED (only the alert events enter its queue — it answers
|
|
40
|
+
# payloads a full-traffic stream would otherwise overflow away) and, on an
|
|
41
|
+
# overflow close, RE-SUBSCRIBES: a consumer that never re-binds is how alerts
|
|
42
|
+
# stop in silence (WS6).
|
|
43
|
+
def start(parent:)
|
|
44
|
+
parent.async do |t|
|
|
45
|
+
t.annotate("insika-alerts")
|
|
46
|
+
loop do
|
|
47
|
+
subscription = @event_stream.subscribe(types: ALERT_TYPES)
|
|
48
|
+
subscription.each { |event| handle(event) }
|
|
49
|
+
# the subscription closed (its overflow path) — alerts must not die here
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The event -> outbox row. Cheap (one transactional write); the DELIVERY is
|
|
56
|
+
# the tick's job. Never raises: an alerting failure must not break the turn.
|
|
57
|
+
def handle(event)
|
|
58
|
+
type = event.type.to_s.to_sym
|
|
59
|
+
return unless ALERT_TYPES.include?(type)
|
|
60
|
+
|
|
61
|
+
agent = agent_for(event)
|
|
62
|
+
return if agent.nil?
|
|
63
|
+
|
|
64
|
+
profile = @profiles.respond_to?(:fetch) ? @profiles.fetch(agent.to_s) : nil
|
|
65
|
+
return if profile.nil?
|
|
66
|
+
|
|
67
|
+
url = profile&.respond_to?(:alerts) ? profile.alerts&.dig("webhook") : nil
|
|
68
|
+
return if Coercion.blank?(url)
|
|
69
|
+
|
|
70
|
+
record_alert(agent: agent.to_s, url: url.to_s, event: event)
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# The agent the alert belongs to: the event carries it for the alerts the
|
|
78
|
+
# engine emits with context (budget_warning / breaker_open); a
|
|
79
|
+
# delivery_failed resolves its task's command. Guards the loop: a webhook's
|
|
80
|
+
# OWN delivery failing is not re-alerted.
|
|
81
|
+
def agent_for(event)
|
|
82
|
+
case event.type.to_sym
|
|
83
|
+
when :delivery_failed
|
|
84
|
+
channel = event.data[:channel]
|
|
85
|
+
return nil if channel.to_s.start_with?("webhook:") # loop guard
|
|
86
|
+
agent_for_task(event.meta[:task_id])
|
|
87
|
+
else
|
|
88
|
+
event.data[:agent] || agent_for_task(event.meta[:task_id])
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def agent_for_task(task_id)
|
|
93
|
+
return nil if task_id.nil? || @task_store.nil?
|
|
94
|
+
|
|
95
|
+
task = @task_store.find(task_id.to_s)
|
|
96
|
+
command = task&.respond_to?(:command) ? task.command : nil
|
|
97
|
+
return nil unless command.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
payload = command["payload"] || command[:payload] || {}
|
|
100
|
+
payload["agent"] || payload[:agent]
|
|
101
|
+
rescue StandardError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The event's durable record, as the CHANNEL would see it. `to` is the
|
|
106
|
+
# webhook URL; `payload` is the event itself (type/data/meta).
|
|
107
|
+
def record_alert(agent:, url:, event:)
|
|
108
|
+
channel = webhook_id(url)
|
|
109
|
+
@outbox.create(
|
|
110
|
+
channel: channel, to: url,
|
|
111
|
+
task_id: event.meta[:task_id], session_id: event.meta[:session_id],
|
|
112
|
+
payload: { "type" => event.type.to_s, "data" => event.data,
|
|
113
|
+
"meta" => event.meta, "agent" => agent }
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# One channel per URL, registered so ChannelDelivery.sweep can claim it.
|
|
118
|
+
def webhook_id(url)
|
|
119
|
+
@webhook_ids[url] ||= begin
|
|
120
|
+
id = "webhook:#{Digest::SHA1.hexdigest(url)[0, 8]}"
|
|
121
|
+
@channels.register(id, Channels::Webhook.new(url, http: @http))
|
|
122
|
+
id
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Boot face of `webhook_id`: register every configured URL up front (at
|
|
127
|
+
# wiring time, before the boot recovery's channel sweep runs). The url is
|
|
128
|
+
# PROFILE data, so it is known before any alert ever fires.
|
|
129
|
+
def register_all_webhooks
|
|
130
|
+
profiles = @profiles.respond_to?(:all) ? @profiles.all : []
|
|
131
|
+
profiles.each do |profile|
|
|
132
|
+
next unless profile&.respond_to?(:alerts)
|
|
133
|
+
|
|
134
|
+
url = profile.alerts&.dig("webhook")
|
|
135
|
+
webhook_id(url.to_s) unless Coercion.blank?(url)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|