insika 0.2.0 → 0.7.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 +325 -17
- data/README.md +52 -11
- data/bin/insika +895 -8
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +229 -163
- data/docs/API.md +56 -0
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +111 -21
- data/docs/CONTEXT.md +132 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +75 -4
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +146 -2
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +116 -0
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +16 -10
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +65 -6
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +17 -12
- data/docs/RELEASING.md +27 -0
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +6 -6
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +79 -2
- data/docs/SKILLS.md +13 -4
- data/docs/SOAK.md +127 -0
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +220 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +115 -0
- data/docs/improve.md +20 -0
- data/docs/index.md +9 -6
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +202 -10
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +119 -14
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +105 -2
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +107 -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 +1 -1
- 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/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +57 -23
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +889 -11
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +69 -82
- data/lib/insika/dsl/server_boot.rb +92 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +186 -5
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +25 -3
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +29 -6
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +869 -76
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +198 -0
- data/lib/insika/followup_store.rb +306 -0
- 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 +17 -2
- 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/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +382 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +7 -2
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +22 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +37 -17
- 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/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +29 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +316 -0
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- 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 +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +247 -17
- data/lib/insika/server/rack_app.rb +21 -1
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +16 -0
- 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 +21 -10
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1437 -54
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +23 -23
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +308 -10
- data/lib/insika/studio/nav_icons.rb +19 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +62 -278
- data/lib/insika/studio/views/agents.erb +71 -54
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- 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 +6 -3
- 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 +106 -66
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +15 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +233 -124
- data/lib/insika/studio/views/settings.erb +41 -1
- data/lib/insika/studio/views/skills.erb +1 -2
- 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 +24 -10
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +47 -11
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +89 -0
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +49 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +472 -13
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +149 -3
- metadata +181 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Template gallery: example agents shipped INSIDE the gem
|
|
7
|
+
# (`lib/insika/templates/<name>/{agent.rb,README.md}`), one DSL file per
|
|
8
|
+
# template that is BOTH doors — `insika new <name>` copies it for the user
|
|
9
|
+
# to run and edit, and this module `evaluate`s the same file to hand its
|
|
10
|
+
# pack(s) to the Studio's "New from template" gallery. No parallel pack
|
|
11
|
+
# format to drift.
|
|
12
|
+
#
|
|
13
|
+
# A template's `agent.rb` guards its CLI demo footer with
|
|
14
|
+
# `if __FILE__ == $PROGRAM_NAME` (false when this module evaluates it) and
|
|
15
|
+
# ends with the bare `Insika.agent`/`Insika.system` result as its LAST
|
|
16
|
+
# expression, so `evaluate` gets it back as the string-eval's return value
|
|
17
|
+
# — no registration call, no second source of truth.
|
|
18
|
+
module Templates
|
|
19
|
+
ROOT = File.expand_path("templates", __dir__)
|
|
20
|
+
|
|
21
|
+
Entry = Data.define(:name, :title, :trail, :description, :capabilities, :studio, :env, :requires) do
|
|
22
|
+
def studio? = studio
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# -> [String] template dirs that have an agent.rb, lexicographic.
|
|
28
|
+
def names
|
|
29
|
+
return [] unless Dir.exist?(ROOT)
|
|
30
|
+
|
|
31
|
+
Dir.children(ROOT).select { |n| File.file?(agent_path(n)) }.sort
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# -> [Entry] every template, parsed metadata only (no evaluation — cheap,
|
|
35
|
+
# safe to call on every render of the Studio gallery).
|
|
36
|
+
def all = names.map { |n| read(n) }
|
|
37
|
+
|
|
38
|
+
# -> Entry for one template. Raises NotFoundError for an unknown name —
|
|
39
|
+
# same discipline as a missing agent/MCP instance.
|
|
40
|
+
def read(name)
|
|
41
|
+
path = agent_path(name)
|
|
42
|
+
raise Insika::NotFoundError, "template '#{name}' not found" unless File.file?(path)
|
|
43
|
+
|
|
44
|
+
meta = frontmatter(File.read(path))
|
|
45
|
+
Entry.new(
|
|
46
|
+
name: name.to_s, title: presence(meta["title"]) || name.to_s, trail: presence(meta["trail"]),
|
|
47
|
+
description: meta["description"].to_s,
|
|
48
|
+
capabilities: split_list(meta["capabilities"]),
|
|
49
|
+
studio: meta.fetch("studio", true) != false,
|
|
50
|
+
env: split_list(meta["env"]), requires: presence(meta["requires"])
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Evaluates the template's agent.rb in an ISOLATED scope (a fresh Object's
|
|
55
|
+
# instance_eval) and returns whatever its last expression is — the built
|
|
56
|
+
# `Insika::DSL::Definition` or `Insika::DSL::System`. $PROGRAM_NAME here is
|
|
57
|
+
# whatever process called this (rspec, the CLI, the Studio server), never
|
|
58
|
+
# this file's path, so the template's own `if __FILE__ == $PROGRAM_NAME`
|
|
59
|
+
# demo footer never runs: no network call, no ARGV parsing, no puts.
|
|
60
|
+
#
|
|
61
|
+
# The fresh-Object receiver keeps a template's local variables and `def`s
|
|
62
|
+
# from leaking into the next one evaluated in the same process; a
|
|
63
|
+
# top-level CONSTANT would still leak (Ruby scopes constant assignment
|
|
64
|
+
# lexically, not by `self`) — wave-1 templates simply don't declare any
|
|
65
|
+
# (the conformance spec, would catch a future one that did).
|
|
66
|
+
def evaluate(name)
|
|
67
|
+
path = agent_path(name)
|
|
68
|
+
raise Insika::NotFoundError, "template '#{name}' not found" unless File.file?(path)
|
|
69
|
+
|
|
70
|
+
Object.new.instance_eval(File.read(path), path)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# -> [Pack] one per agent, regardless of whether the template is a single
|
|
74
|
+
# `Insika.agent` (Definition#to_pack) or a system (System#to_packs).
|
|
75
|
+
def packs_for(name)
|
|
76
|
+
built = evaluate(name)
|
|
77
|
+
built.respond_to?(:to_packs) ? built.to_packs : [built.to_pack]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def agent_path(name) = File.join(ROOT, name.to_s, "agent.rb")
|
|
81
|
+
def readme_path(name) = File.join(ROOT, name.to_s, "README.md")
|
|
82
|
+
|
|
83
|
+
# A `# ---` … `# ---` comment block at the very top of the file, YAML
|
|
84
|
+
# inside (each line stripped of its leading `# `). Not real Ruby
|
|
85
|
+
# frontmatter (there's no such thing) — a convention this module alone
|
|
86
|
+
# parses, so the metadata lives in the one file without needing a
|
|
87
|
+
# side-channel manifest.
|
|
88
|
+
def frontmatter(source)
|
|
89
|
+
lines = source.lines
|
|
90
|
+
# Every template starts with the same magic comment every other .rb
|
|
91
|
+
# file in the gem does — skip it (and any blank line) before looking
|
|
92
|
+
# for the block, so templates don't have to break that convention.
|
|
93
|
+
lines = lines.drop(1) while lines.first && (lines.first.strip.empty? || lines.first.strip == "# frozen_string_literal: true")
|
|
94
|
+
return {} unless lines.first&.strip == "# ---"
|
|
95
|
+
|
|
96
|
+
body = lines.drop(1)
|
|
97
|
+
.take_while { |l| l.strip != "# ---" }
|
|
98
|
+
.map { |l| l.sub(/\A#\s?/, "") }
|
|
99
|
+
.join
|
|
100
|
+
YAML.safe_load(body) || {}
|
|
101
|
+
end
|
|
102
|
+
private_class_method :frontmatter
|
|
103
|
+
|
|
104
|
+
def split_list(value)
|
|
105
|
+
value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
106
|
+
end
|
|
107
|
+
private_class_method :split_list
|
|
108
|
+
|
|
109
|
+
def presence(str) = Insika::Coercion.presence(str)
|
|
110
|
+
private_class_method :presence
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -69,18 +69,18 @@ RSpec.shared_examples "an Insika store" do
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
it " converts Symbols (keys and values) to Strings" do #
|
|
72
|
-
store.set("s", "k", {
|
|
73
|
-
expect(store.get("s", "k")).to eq({ "
|
|
72
|
+
store.set("s", "k", { key: :value })
|
|
73
|
+
expect(store.get("s", "k")).to eq({ "key" => "value" })
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
it " returns nil for an absent key, never an exception" do #
|
|
77
|
-
expect(store.get("s", "
|
|
77
|
+
expect(store.get("s", "missing-key")).to be_nil
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
it " overwrites silently (last-write-wins)" do #
|
|
81
|
-
store.set("s", "k", "
|
|
82
|
-
store.set("s", "k", "
|
|
83
|
-
expect(store.get("s", "k")).to eq("
|
|
81
|
+
store.set("s", "k", "first")
|
|
82
|
+
store.set("s", "k", "second")
|
|
83
|
+
expect(store.get("s", "k")).to eq("second")
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
it " set returns the same object passed in (not the round-trip)" do #
|
|
@@ -128,6 +128,27 @@ RSpec.shared_examples "an Insika store" do
|
|
|
128
128
|
end
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
+
describe "#scopes" do
|
|
132
|
+
it " returns scope names sorted lexicographically" do #
|
|
133
|
+
store.set("zeta", "k", 1)
|
|
134
|
+
store.set("alpha", "k", 1)
|
|
135
|
+
store.set("alpha:child", "k", 1)
|
|
136
|
+
expect(store.scopes).to eq(["alpha", "alpha:child", "zeta"])
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it " filters by prefix with start_with? (not include?)" do #
|
|
140
|
+
store.set("s", "k", 1)
|
|
141
|
+
store.set("s:child", "k", 1)
|
|
142
|
+
store.set("s2", "k", 1) # trap: contains "s" but does not start with "s:"
|
|
143
|
+
expect(store.scopes("s:")).to eq(["s:child"])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it " returns [] when nothing matches (or the store is empty)" do #
|
|
147
|
+
expect(store.scopes("nope")).to eq([])
|
|
148
|
+
expect(store.scopes).to eq([])
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
131
152
|
describe "scope isolation" do
|
|
132
153
|
it " keeps scopes independent in get/list/delete" do #
|
|
133
154
|
store.set("s1", "k", 1)
|
data/lib/insika/tick.rb
CHANGED
|
@@ -3,21 +3,23 @@
|
|
|
3
3
|
require "time"
|
|
4
4
|
|
|
5
5
|
module Insika
|
|
6
|
-
|
|
7
|
-
# pass does
|
|
6
|
+
# The periodic tick: durability stops waiting for a reboot. One
|
|
7
|
+
# pass does three things, in this order:
|
|
8
8
|
#
|
|
9
9
|
# 1. DRAIN the outbox (`ChannelDelivery#sweep`) — replies a previous pass
|
|
10
10
|
# (or process) recorded and never claimed. Ungated: every record carries
|
|
11
11
|
# its own transactional claim, so N workers draining is safe.
|
|
12
|
-
# 2.
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
12
|
+
# 2. The engine's background duties, each gated by its OWN claim window so
|
|
13
|
+
# their O(n) scans never ride the 60 s loop: retention (daily), the
|
|
14
|
+
# outcome fold, the follow-up firer, and the recurring-schedule firer
|
|
15
|
+
# (the engine's own cron — it superseded the "point your own cron at
|
|
16
|
+
# the route" decision, see docs/SCHEDULING.md).
|
|
17
|
+
# 3. SWEEP stale orphaned tasks (`Recovery#run(stale_after:)`) — gated by a
|
|
18
|
+
# bucketed claim, so exactly one worker per window sweeps. The staleness
|
|
19
|
+
# threshold is the liveness gate: a live :running turn is bounded by
|
|
20
|
+
# turn_timeout, so anything untouched past it cannot be alive.
|
|
17
21
|
#
|
|
18
|
-
# It is NOT a job queue: no schedules, no priorities, no fan-out.
|
|
19
|
-
# refinement hook once pictured here is dropped by merit —
|
|
20
|
-
# docs/REFINEMENT.md's "no scheduler in the engine" stands.
|
|
22
|
+
# It is NOT a job queue: no schedules queue, no priorities, no fan-out.
|
|
21
23
|
class Tick
|
|
22
24
|
# 60s: a customer waiting on WhatsApp is the deadline. 900s = 3x the
|
|
23
25
|
# default turn_timeout (300s) — the rule, not the number: the threshold
|
|
@@ -31,7 +33,8 @@ module Insika
|
|
|
31
33
|
|
|
32
34
|
def initialize(store:, recovery:, channel_delivery:, logger: nil,
|
|
33
35
|
interval: DEFAULT_INTERVAL, stale_after: DEFAULT_STALE_AFTER,
|
|
34
|
-
sleeper: nil
|
|
36
|
+
sleeper: nil, retention: nil, funnel: nil, followup: nil,
|
|
37
|
+
schedule: nil)
|
|
35
38
|
@store = store
|
|
36
39
|
@recovery = recovery
|
|
37
40
|
@channel_delivery = channel_delivery
|
|
@@ -39,8 +42,25 @@ module Insika
|
|
|
39
42
|
@interval = interval.to_i
|
|
40
43
|
@stale_after = stale_after.to_i
|
|
41
44
|
@sleeper = sleeper || method(:default_sleep)
|
|
45
|
+
@retention = retention # the daily age-based sweep; nil = none
|
|
46
|
+
@funnel = funnel # the tick-driven outcome fold; nil = none
|
|
47
|
+
@followup = followup # the tick-driven follow-up firer; nil = none
|
|
48
|
+
@schedule = schedule # the recurring-schedule firer; nil = none
|
|
42
49
|
end
|
|
43
50
|
|
|
51
|
+
# the fold is wired after the Tick is built (the graph passes
|
|
52
|
+
# it to `executor.tick.funnel =` — the outcome/funnel stores come from the
|
|
53
|
+
# spine). Setter + kwarg: same shape as `retention`.
|
|
54
|
+
attr_accessor :funnel
|
|
55
|
+
|
|
56
|
+
# the follow-up firer, wired after the Tick is built (same
|
|
57
|
+
# shape as `funnel` — the stores come from the spine).
|
|
58
|
+
attr_accessor :followup
|
|
59
|
+
|
|
60
|
+
# the recurring-schedule firer, wired after the Tick is built
|
|
61
|
+
# (same shape — the stores come from the spine).
|
|
62
|
+
attr_accessor :schedule
|
|
63
|
+
|
|
44
64
|
def enabled? = @interval.positive?
|
|
45
65
|
|
|
46
66
|
# One pass, pure (no reactor needed): the serving loop calls it on a timer,
|
|
@@ -49,6 +69,22 @@ module Insika
|
|
|
49
69
|
def run_once
|
|
50
70
|
drained = @channel_delivery ? @channel_delivery.sweep : { dispatched: [] }
|
|
51
71
|
summary = { dispatched: drained[:dispatched], resumed: [], failed: [] }
|
|
72
|
+
# WS8 retention: cheap when not claimed (its own daily window) — the
|
|
73
|
+
# O(n) scans never ride the 60 s loop.
|
|
74
|
+
retention_summary = @retention&.run
|
|
75
|
+
summary[:retention] = retention_summary if retention_summary
|
|
76
|
+
# the outcome fold — one pass per claim window, cheap
|
|
77
|
+
# when another worker holds it. Sits next to retention, on the same tick.
|
|
78
|
+
funnel_summary = @funnel&.run
|
|
79
|
+
summary[:funnel] = funnel_summary if funnel_summary
|
|
80
|
+
# the follow-up firer — the tick's third duty, gated by its
|
|
81
|
+
# OWN claim window so the O(n) scans never ride the 60 s loop.
|
|
82
|
+
followup_summary = @followup&.run
|
|
83
|
+
summary[:followup] = followup_summary if followup_summary
|
|
84
|
+
# the recurring-schedule firer — the tick's fourth duty,
|
|
85
|
+
# the same claim-window discipline as the follow-up firer.
|
|
86
|
+
schedule_summary = @schedule&.run
|
|
87
|
+
summary[:schedule] = schedule_summary if schedule_summary
|
|
52
88
|
return summary unless claim_window
|
|
53
89
|
|
|
54
90
|
result = @recovery.run(stale_after: @stale_after)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# IANA timezone handling through the OS tz database — the
|
|
5
|
+
# engine's only route to a zone NAME (Ruby stdlib's `Time#getlocal` takes an
|
|
6
|
+
# offset, not a zone name). Shared by FollowupPolicy (quiet hours), the cron
|
|
7
|
+
# parser (next-fire materialization) and the doctor (zone existence).
|
|
8
|
+
#
|
|
9
|
+
# IANA names are resolved by pointing Ruby's `TZ` at the zone for the
|
|
10
|
+
# computation. Save/restore keeps the global intact; under the engine's
|
|
11
|
+
# cooperative fiber model — no IO between the save and the restore — the
|
|
12
|
+
# mutation is atomic on the calling fiber.
|
|
13
|
+
module Timezone
|
|
14
|
+
# The candidate tz-data roots (TZDIR first — Ruby's own lookup env). The
|
|
15
|
+
# zone name maps to a FILE under the root ("America/Sao_Paulo" ->
|
|
16
|
+
# "America/Sao_Paulo").
|
|
17
|
+
TZ_ROOTS = ([ENV["TZDIR"]] +
|
|
18
|
+
%w[/usr/share/zoneinfo /usr/share/lib/zoneinfo /etc/zoneinfo])
|
|
19
|
+
.compact.freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# -> bool: is `zone` an IANA name the OS tz database knows? A bogus zone
|
|
24
|
+
# is a malformed declaration — refused where the doctor can name it (an
|
|
25
|
+
# unknown ENV["TZ"] silently behaves as UTC, so existence is checked
|
|
26
|
+
# against the database, not by asking Time).
|
|
27
|
+
def known?(zone)
|
|
28
|
+
zone = zone.to_s
|
|
29
|
+
return true if zone == "UTC" || zone == "Etc/UTC"
|
|
30
|
+
|
|
31
|
+
TZ_ROOTS.any? { |root| File.directory?(root) && File.exist?(File.join(root, zone)) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Yields `time` interpreted in the given IANA zone (via a save/restore of
|
|
35
|
+
# ENV["TZ"] — the stdlib-only route to the OS tz database). Returns the
|
|
36
|
+
# block's value.
|
|
37
|
+
def in_zone(zone, time)
|
|
38
|
+
previous = ENV["TZ"]
|
|
39
|
+
ENV["TZ"] = zone.to_s
|
|
40
|
+
yield time
|
|
41
|
+
ensure
|
|
42
|
+
ENV["TZ"] = previous
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -34,7 +34,8 @@ module Insika
|
|
|
34
34
|
# (masks/reconciles); the definition itself is agnostic to masking.
|
|
35
35
|
ToolDefinition = Data.define(
|
|
36
36
|
:name, :description, :parameters, :request, :response,
|
|
37
|
-
:secret_headers, :side_effect, :timeout, :group, :tags, :halt_when
|
|
37
|
+
:secret_headers, :side_effect, :timeout, :group, :tags, :halt_when,
|
|
38
|
+
:evidence # Insika::Evidence::Spec | nil
|
|
38
39
|
)
|
|
39
40
|
|
|
40
41
|
class ToolDefinition
|
|
@@ -46,7 +47,7 @@ module Insika
|
|
|
46
47
|
ARRAY_SUGAR = PARAM_TYPES.map { |t| "array:#{t}" }.freeze
|
|
47
48
|
HTTP_METHODS = %w[GET HEAD POST PUT PATCH DELETE].freeze
|
|
48
49
|
IDEMPOTENT = %w[GET HEAD].freeze # side_effect default = false
|
|
49
|
-
EXTRACTS = %w[body_raw status json_path].freeze
|
|
50
|
+
EXTRACTS = %w[body_raw status json_path evidence_envelope].freeze
|
|
50
51
|
NAME_RE = /\A[a-z][a-z0-9_]*\z/ # identifier for the model
|
|
51
52
|
# A `.` in the placeholder enables the turn-context namespace `{{ctx.*}}`
|
|
52
53
|
# separate from the model's `{{param}}`. Params follow NAME_RE (no
|
|
@@ -56,7 +57,7 @@ module Insika
|
|
|
56
57
|
# resolved by DataDefinedTool. Closed allowlist (a typo becomes a validation
|
|
57
58
|
# error, not a silently empty header).
|
|
58
59
|
CTX_PREFIX = "ctx."
|
|
59
|
-
CTX_FIELDS = %w[chat_id store_id agent_id tenant].freeze
|
|
60
|
+
CTX_FIELDS = %w[chat_id store_id agent_id tenant image_url].freeze
|
|
60
61
|
|
|
61
62
|
# ---- safe subset of JSON Schema (R1) --------------------------------------
|
|
62
63
|
# Types supported by EVERY provider (OpenAI/Anthropic/Gemini/DeepSeek/Bedrock).
|
|
@@ -74,7 +75,7 @@ module Insika
|
|
|
74
75
|
# `parameters` accepts JSON Schema (Hash) OR the legacy flat array.
|
|
75
76
|
def self.build(name:, description:, request:, parameters: nil, response: nil,
|
|
76
77
|
secret_headers: nil, side_effect: nil, timeout: nil, group: nil, tags: nil,
|
|
77
|
-
halt_when: nil)
|
|
78
|
+
halt_when: nil, evidence: nil)
|
|
78
79
|
name = name.to_s
|
|
79
80
|
raise Insika::ValidationError, "name must match #{NAME_RE.inspect}" unless NAME_RE.match?(name)
|
|
80
81
|
|
|
@@ -84,6 +85,10 @@ module Insika
|
|
|
84
85
|
schema = normalize_params(parameters)
|
|
85
86
|
req = normalize_request(request, top_level_names(schema))
|
|
86
87
|
resp = normalize_response(response)
|
|
88
|
+
if resp[:extract] == "evidence_envelope" && evidence.nil?
|
|
89
|
+
raise Insika::ValidationError,
|
|
90
|
+
"extract 'evidence_envelope' requires an 'evidence' declaration"
|
|
91
|
+
end
|
|
87
92
|
|
|
88
93
|
method = req[:method]
|
|
89
94
|
effect = side_effect.nil? ? !IDEMPOTENT.include?(method) : (side_effect ? true : false)
|
|
@@ -93,7 +98,8 @@ module Insika
|
|
|
93
98
|
secret_headers: Array(secret_headers).map(&:to_s), side_effect: effect,
|
|
94
99
|
timeout: timeout.nil? ? nil : Integer(timeout),
|
|
95
100
|
group: normalize_group(group), tags: normalize_tags(tags),
|
|
96
|
-
halt_when: normalize_halt_when(halt_when)
|
|
101
|
+
halt_when: normalize_halt_when(halt_when),
|
|
102
|
+
evidence: Insika::Evidence::Spec.parse(evidence)
|
|
97
103
|
)
|
|
98
104
|
end
|
|
99
105
|
|
|
@@ -104,7 +110,7 @@ module Insika
|
|
|
104
110
|
name: h[:name], description: h[:description], parameters: h[:parameters],
|
|
105
111
|
request: h[:request] || {}, response: h[:response],
|
|
106
112
|
secret_headers: h[:secret_headers], side_effect: h[:side_effect], timeout: h[:timeout],
|
|
107
|
-
group: h[:group], tags: h[:tags], halt_when: h[:halt_when]
|
|
113
|
+
group: h[:group], tags: h[:tags], halt_when: h[:halt_when], evidence: h[:evidence]
|
|
108
114
|
)
|
|
109
115
|
end
|
|
110
116
|
|
|
@@ -405,7 +411,7 @@ module Insika
|
|
|
405
411
|
# String-keyed Hash for persistence (ConfigStore stringifies again, but we
|
|
406
412
|
# normalize here so the record is stable across backends).
|
|
407
413
|
def to_h
|
|
408
|
-
{
|
|
414
|
+
h = {
|
|
409
415
|
"name" => name, "description" => description,
|
|
410
416
|
"parameters" => parameters,
|
|
411
417
|
"request" => request.transform_keys(&:to_s),
|
|
@@ -415,6 +421,10 @@ module Insika
|
|
|
415
421
|
"group" => group, "tags" => tags,
|
|
416
422
|
"halt_when" => halt_when&.transform_keys(&:to_s)
|
|
417
423
|
}
|
|
424
|
+
# present only when declared — a tool without evidence is byte-identical
|
|
425
|
+
# to today (no declaration, no envelope processing).
|
|
426
|
+
h["evidence"] = evidence.to_h if evidence
|
|
427
|
+
h
|
|
418
428
|
end
|
|
419
429
|
|
|
420
430
|
# -> true when this response ENDS the turn (no further model call). `body` is the
|
data/lib/insika/tool_envelope.rb
CHANGED
|
@@ -56,6 +56,11 @@ module Insika
|
|
|
56
56
|
|
|
57
57
|
started = monotonic
|
|
58
58
|
result = with_gate { Async::Task.current.with_timeout(@timeout, ToolTimeout) { __getobj__.call(args) } }
|
|
59
|
+
# the ONE seam every tool result passes on its way to the model.
|
|
60
|
+
# For a declared-evidence tool: reshape to the lean envelope, record the ids
|
|
61
|
+
# on the ledger, hoard the attachments. No evidence = the result passes
|
|
62
|
+
# through untouched (one nil-check — parity).
|
|
63
|
+
result = process_evidence(result)
|
|
59
64
|
record_side_effect!(call_id) if side_effect?
|
|
60
65
|
trace(call_id, args, result, started)
|
|
61
66
|
result
|
|
@@ -136,5 +141,69 @@ module Insika
|
|
|
136
141
|
@checkpoint_store.record_side_effect(@state.task.id, turn: @state.turn,
|
|
137
142
|
tool_call_id: call_id)
|
|
138
143
|
end
|
|
144
|
+
|
|
145
|
+
# ---- evidence ---------------------------------------------
|
|
146
|
+
|
|
147
|
+
# The evidence spec for the wrapped tool (D4). Resolution order:
|
|
148
|
+
# 1. the wrapped tool responds to `evidence` -> its spec (the data-tool
|
|
149
|
+
# path — DataDefinedTool exposes its definition's evidence);
|
|
150
|
+
# 2. otherwise the tool_registry entry's metadata carries an `evidence`
|
|
151
|
+
# spec (the code-tool path — a registry tool opts in at registration).
|
|
152
|
+
# No spec = pass the result through untouched (parity, byte-identical).
|
|
153
|
+
def evidence_spec
|
|
154
|
+
tool = __getobj__
|
|
155
|
+
if tool.respond_to?(:evidence)
|
|
156
|
+
raw = tool.evidence
|
|
157
|
+
return raw && Insika::Evidence::Spec.parse(raw)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
entry = @tool_registry.respond_to?(:entries) ? registry_entry(real_name) : nil
|
|
161
|
+
metadata = entry&.respond_to?(:metadata) ? entry.metadata : nil
|
|
162
|
+
raw = metadata && (metadata[:evidence] || metadata["evidence"])
|
|
163
|
+
raw && Insika::Evidence::Spec.parse(raw)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def registry_entry(name)
|
|
167
|
+
@tool_registry.entries.find { |e| e.name == name.to_s }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# -> result (possibly reshaped). NEVER raises out: a broken evidence result
|
|
171
|
+
# becomes the envelope error the model can act on, exactly like a malformed
|
|
172
|
+
# CALL is today. A tool ERROR result is never reshaped (an error must reach
|
|
173
|
+
# the model verbatim — the DataDefinedTool rule).
|
|
174
|
+
def process_evidence(result)
|
|
175
|
+
spec = evidence_spec
|
|
176
|
+
return result unless spec
|
|
177
|
+
return result if result.is_a?(Hash) && (result[:error] || result["error"])
|
|
178
|
+
|
|
179
|
+
raw = Insika::Evidence::Processor.raw(spec, result)
|
|
180
|
+
bad = Insika::SchemaGuard.violation_output(spec, raw)
|
|
181
|
+
return { error: bad } if bad
|
|
182
|
+
|
|
183
|
+
lean, attachments = Insika::Evidence::Processor.build(spec, raw)
|
|
184
|
+
record_evidence!(spec, lean)
|
|
185
|
+
hoard_attachments!(attachments)
|
|
186
|
+
lean
|
|
187
|
+
rescue StandardError => e
|
|
188
|
+
{ error: "evidence processing failed: #{e.message}" }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Ledger write + attachment hoarding, both via the state (duck-typed — the
|
|
192
|
+
# envelope's existing specs construct state stubs without these readers).
|
|
193
|
+
def record_evidence!(_spec, lean)
|
|
194
|
+
ledger = @state.respond_to?(:evidence_ledger) ? @state.evidence_ledger : nil
|
|
195
|
+
return unless ledger
|
|
196
|
+
|
|
197
|
+
ids = Array(lean["items"]).map { |i| i["id"] }
|
|
198
|
+
ledger.record(ids) unless ids.empty?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def hoard_attachments!(attachments)
|
|
202
|
+
return if attachments.empty?
|
|
203
|
+
return unless @state.respond_to?(:evidence_attachments)
|
|
204
|
+
|
|
205
|
+
@state.evidence_attachments ||= []
|
|
206
|
+
@state.evidence_attachments.concat(attachments)
|
|
207
|
+
end
|
|
139
208
|
end
|
|
140
209
|
end
|
data/lib/insika/tool_manifest.rb
CHANGED
|
@@ -77,7 +77,11 @@ module Insika
|
|
|
77
77
|
"tags" => (Array(defaults["tags"]) | Array(t["tags"])), # inherited default; tags unioned
|
|
78
78
|
# Per-tool only: "this result ends the turn" is a property of THIS backend's
|
|
79
79
|
# response, never something a manifest default should hand to its siblings.
|
|
80
|
-
"halt_when" => t["halt_when"]
|
|
80
|
+
"halt_when" => t["halt_when"],
|
|
81
|
+
# the evidence declaration (lean envelope + ledger feed are the
|
|
82
|
+
# SAME data contract — D1). Normalized here; a malformed declaration is a
|
|
83
|
+
# ValidationError at ingestion, isolable per tool (R4).
|
|
84
|
+
"evidence" => t["evidence"] && Insika::Evidence::Spec.parse(t["evidence"]).to_h
|
|
81
85
|
}.compact
|
|
82
86
|
end
|
|
83
87
|
|
|
@@ -43,6 +43,11 @@ module Insika
|
|
|
43
43
|
def name = @definition.name
|
|
44
44
|
def description = @definition.description
|
|
45
45
|
|
|
46
|
+
# the tool's own evidence declaration (a Spec | nil). The
|
|
47
|
+
# envelope's duck-typed resolution checks this FIRST — a data-tool declares
|
|
48
|
+
# its evidence on its definition, never in the registry metadata.
|
|
49
|
+
def evidence = @definition.evidence
|
|
50
|
+
|
|
46
51
|
# FULL (nested) JSON Schema straight into RubyLLM's params_schema — it is what
|
|
47
52
|
# the providers serialize (OpenAI/Anthropic/Gemini/Bedrock prefer
|
|
48
53
|
# params_schema; parameters is just a fallback). Provider-agnostic and
|
|
@@ -148,6 +153,11 @@ module Insika
|
|
|
148
153
|
when "status" then { status: result[:status] }
|
|
149
154
|
when "body_raw" then http_ok?(result) ? result[:body] : http_error(result)
|
|
150
155
|
when "json_path" then extract_json(result)
|
|
156
|
+
# the raw response body under an envelope-only key, so the
|
|
157
|
+
# ToolEnvelope can parse items/attachments. A non-2xx is an ERROR like
|
|
158
|
+
# any other extract — an error must reach the model verbatim.
|
|
159
|
+
when "evidence_envelope"
|
|
160
|
+
http_ok?(result) ? { "__insika_body" => result[:body].to_s } : http_error(result)
|
|
151
161
|
end
|
|
152
162
|
end
|
|
153
163
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Tools
|
|
7
|
+
# The agent's IMAGE output (WS9, saída). The engine transports media, never
|
|
8
|
+
# meaning: what the image IS for (a virtual try-on, a product mockup) is the
|
|
9
|
+
# skill's business — the contract here just produces the bytes and carries
|
|
10
|
+
# them in the turn's `output_parts`.
|
|
11
|
+
#
|
|
12
|
+
# Wired ONLY when both gates pass (ChatBuilder): the agent opted in
|
|
13
|
+
# (`outputs.image`) AND the channel declared it can receive the media
|
|
14
|
+
# (`channel.capabilities` includes "image_output") — nothing leaks by
|
|
15
|
+
# default. The image is an envelope part, never part of the answer text;
|
|
16
|
+
# the provider's tokens are merged into the turn's usage like any ask.
|
|
17
|
+
#
|
|
18
|
+
# Also EDITS — `source_image_urls` (or, absent that, the turn's
|
|
19
|
+
# own inbound photo) rides `paint(with:)`; `mask_url` rides `paint(mask:)`.
|
|
20
|
+
# What the edit MEANS (a try-on, a mockup) is the skill's business; this
|
|
21
|
+
# tool only transports the bytes.
|
|
22
|
+
class GenerateImage < RubyLLM::Tool
|
|
23
|
+
description "Generate an image, or EDIT one, and attach it to the reply as an " \
|
|
24
|
+
"output part. Use when the customer asked for a picture, or asked to " \
|
|
25
|
+
"transform/edit a photo (a virtual try-on, a mockup on their wall, a " \
|
|
26
|
+
"touch-up). Omitting source_image_urls generates a new image from the " \
|
|
27
|
+
"prompt alone — UNLESS this turn carries an inbound photo, in which case " \
|
|
28
|
+
"that photo is edited by default (pass source_image_urls explicitly to " \
|
|
29
|
+
"generate from scratch instead)."
|
|
30
|
+
# explicit JSON-schema form (the `param` DSL only reaches strings/scalars,
|
|
31
|
+
# and source_image_urls needs a typed array — the bare-array gotcha, #128).
|
|
32
|
+
params(
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
prompt: { type: "string", description: "What to draw, or what edit to make, in detail" },
|
|
36
|
+
size: { type: "string",
|
|
37
|
+
description: "Optional canvas size, e.g. 1024x1024 (default from the agent config)" },
|
|
38
|
+
source_image_urls: {
|
|
39
|
+
type: "array",
|
|
40
|
+
description: "Image URLs to edit instead of generating from scratch — e.g. " \
|
|
41
|
+
"the photo the customer just sent in this conversation " \
|
|
42
|
+
"({{ctx.image_url}}), or any other URL from this chat. Omit to use " \
|
|
43
|
+
"the turn's inbound photo by default (if any), or to generate a " \
|
|
44
|
+
"fresh image when there is none.",
|
|
45
|
+
items: { type: "string" }
|
|
46
|
+
},
|
|
47
|
+
mask_url: { type: "string",
|
|
48
|
+
description: "Optional mask image URL marking which area of the " \
|
|
49
|
+
"source(s) to edit (transparent = editable)" }
|
|
50
|
+
},
|
|
51
|
+
required: %w[prompt]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def name = "generate_image"
|
|
55
|
+
|
|
56
|
+
# runner: a duck exposing #generate_media_output(:image, prompt, config)
|
|
57
|
+
# -> [part, usage] and #account_media_usage(part, usage) (the Executor).
|
|
58
|
+
def initialize(runner:, config:, state:, **)
|
|
59
|
+
@runner = runner
|
|
60
|
+
@config = config
|
|
61
|
+
@state = state
|
|
62
|
+
super()
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def execute(prompt:, size: nil, source_image_urls: nil, mask_url: nil)
|
|
66
|
+
cfg = @config.merge("size" => size.to_s, "mask_url" => mask_url.to_s)
|
|
67
|
+
.reject { |_, v| v.to_s.empty? }
|
|
68
|
+
cfg = cfg.merge(source_config(source_image_urls))
|
|
69
|
+
part, usage = @runner.generate_media_output(:image, prompt.to_s, cfg)
|
|
70
|
+
@state.output_parts << part
|
|
71
|
+
@runner.account_media_usage(@state, part, usage)
|
|
72
|
+
"image generated and attached to the reply (#{part["mime_type"]})"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Explicit URLs win over the default; a turn with inbound images (no
|
|
78
|
+
# explicit URLs) hands `Output.generate_image` the ALREADY-FETCHED
|
|
79
|
+
# attachments (bypassing the URL fetch — they are bytes we hold).
|
|
80
|
+
def source_config(source_image_urls)
|
|
81
|
+
urls = Array(source_image_urls).map(&:to_s).reject(&:empty?)
|
|
82
|
+
return { "source_urls" => urls } if urls.any?
|
|
83
|
+
return {} unless Array(@state.image_attachments).any?
|
|
84
|
+
|
|
85
|
+
{ "source_attachments" => @state.image_attachments }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|