insika 0.0.1 → 0.1.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 +295 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +351 -0
- data/docs/AGENTS.md +494 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +100 -0
- data/docs/DEPLOY.md +334 -0
- data/docs/EMBEDDING.md +194 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +231 -0
- data/docs/OBSERVABILITY.md +365 -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 +362 -0
- data/docs/SKILLS.md +98 -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 +188 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +150 -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/chat_builder.rb +254 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/coercion.rb +50 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +41 -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_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/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/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 +71 -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 +50 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +85 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +19 -0
- data/lib/insika/context/priority.rb +29 -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 +108 -0
- data/lib/insika/context/providers/skill.rb +20 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +294 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +379 -0
- data/lib/insika/dsl/server_boot.rb +97 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +307 -0
- data/lib/insika/edge_limiter.rb +130 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +246 -0
- data/lib/insika/errors.rb +145 -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 +114 -0
- data/lib/insika/executor.rb +1680 -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/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 +114 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +103 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +121 -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 +137 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +127 -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/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 +87 -0
- data/lib/insika/safety/moderator.rb +86 -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/admin_auth.rb +29 -0
- data/lib/insika/server/app.rb +850 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +110 -0
- data/lib/insika/server/responses.rb +155 -0
- data/lib/insika/server/sse_body.rb +96 -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 +113 -0
- data/lib/insika/skill_store.rb +79 -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 +1571 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +69 -0
- data/lib/insika/studio/forms.rb +340 -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 +62 -0
- data/lib/insika/studio/views/settings.erb +173 -0
- data/lib/insika/studio/views/skills.erb +86 -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/token_estimator.rb +16 -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_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 +220 -0
- data/lib/insika/tools/load_skill.rb +41 -0
- data/lib/insika/tools/remember.rb +53 -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 +158 -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 +198 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +203 -4
- metadata +395 -8
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
# 1 fragment per EVICTION UNIT (§11 R1): a plain message, OR an
|
|
21
|
+
# assistant-with-tool_calls together with its tool results. Grouping at
|
|
22
|
+
# the fragment level means the budget cut (apply_budget) drops a whole
|
|
23
|
+
# tool cycle atomically — a tool_use is NEVER seeded without its result
|
|
24
|
+
# (which providers reject), without touching apply_budget itself.
|
|
25
|
+
eviction_units(messages).each_with_index.map do |unit, idx|
|
|
26
|
+
ContextFragment.build(
|
|
27
|
+
# single message stays a Hash (compat with existing fragments); a
|
|
28
|
+
# multi-message cycle is an Array (seed_history flattens it back).
|
|
29
|
+
content: unit.size == 1 ? unit.first : unit,
|
|
30
|
+
placement: :history,
|
|
31
|
+
# HISTORY_MAX ceiling; idx 0 = oldest (drops first in the cut)
|
|
32
|
+
priority: [Context::Priority::HISTORY_BASE + idx, Context::Priority::HISTORY_MAX].min,
|
|
33
|
+
source: id
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Groups a flat message list into eviction units. An assistant message
|
|
41
|
+
# carrying tool_calls absorbs the `role: tool` messages that immediately
|
|
42
|
+
# follow it (its results). Everything else is a unit of one.
|
|
43
|
+
def eviction_units(messages)
|
|
44
|
+
normalized = messages.map { |m| normalize(m) }
|
|
45
|
+
units = []
|
|
46
|
+
i = 0
|
|
47
|
+
while i < normalized.length
|
|
48
|
+
msg = normalized[i]
|
|
49
|
+
i += 1
|
|
50
|
+
if msg[:role].to_s == "assistant" && tool_calls?(msg)
|
|
51
|
+
cycle = [msg]
|
|
52
|
+
while i < normalized.length && normalized[i][:role].to_s == "tool"
|
|
53
|
+
cycle << normalized[i]
|
|
54
|
+
i += 1
|
|
55
|
+
end
|
|
56
|
+
units << cycle
|
|
57
|
+
else
|
|
58
|
+
units << [msg]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
units
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Symbol-keyed message preserving tool_calls / tool_call_id when present
|
|
65
|
+
# (a plain message keeps just {role, content} — parity with the old shape).
|
|
66
|
+
def normalize(msg)
|
|
67
|
+
h = { role: msg[:role] || msg["role"], content: msg[:content] || msg["content"] }
|
|
68
|
+
tool_calls = msg[:tool_calls] || msg["tool_calls"]
|
|
69
|
+
tool_call_id = msg[:tool_call_id] || msg["tool_call_id"]
|
|
70
|
+
h[:tool_calls] = tool_calls if tool_calls
|
|
71
|
+
h[:tool_call_id] = tool_call_id if tool_call_id
|
|
72
|
+
h
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def tool_calls?(msg) = msg[:tool_calls] && !Array(msg[:tool_calls]).empty?
|
|
76
|
+
|
|
77
|
+
# Precedence: checkpoint -> explicit history -> store.
|
|
78
|
+
# The first present source wins; no merge.
|
|
79
|
+
def transcript_for(request)
|
|
80
|
+
return request.checkpoint.messages if request.checkpoint
|
|
81
|
+
|
|
82
|
+
explicit = explicit_history(request)
|
|
83
|
+
return explicit if explicit
|
|
84
|
+
return session_messages(request.session) if request.session
|
|
85
|
+
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Source 2 (explicit history): the handler passes it in request.vars[:history].
|
|
90
|
+
# Isolated in a single method to change the convention with 1 line.
|
|
91
|
+
def explicit_history(request)
|
|
92
|
+
vars = request.vars.to_h
|
|
93
|
+
vars[:history] || vars["history"]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# CONDITIONAL requiredness: when a session is requested, a read
|
|
97
|
+
# failure becomes a ContextError (aborts the turn); the base required?
|
|
98
|
+
# does not receive the request, so the behavior lives here.
|
|
99
|
+
def session_messages(session)
|
|
100
|
+
@session_store.find(session.id)&.messages || []
|
|
101
|
+
rescue StandardError => e # read failure (exception/StoreError)
|
|
102
|
+
raise ContextError.new("Session provider failed with a requested session: #{e.message}",
|
|
103
|
+
provider: id)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
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 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
|
+
def entries(request) = @catalog.effective(request.profile.skills)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
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,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Durable record of an ASYNC delegation (RFC-0010 §5, item 21 Phase 2, hermes
|
|
8
|
+
# "delegation durability"). The synchronous subagent (Phase 1) 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
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# `insika doctor` (item 23 / §8.1 — OpenClaw's "strict config + doctor --fix",
|
|
5
|
+
# "the repo's best productization discipline"). A read-only diagnosis of a
|
|
6
|
+
# deployment's configuration that turns the scattered, silent boot warnings into
|
|
7
|
+
# ONE structured report — and, with `--fix`, applies the safe autofixes.
|
|
8
|
+
#
|
|
9
|
+
# Every check is DATA (config over convention): an id, a title, and a lambda that
|
|
10
|
+
# inspects the injected collaborators and returns Findings. A Finding may carry a
|
|
11
|
+
# `fix` proc; `fix!` runs those and re-diagnoses. Collaborators are optional so the
|
|
12
|
+
# CLI can build them straight off the durable backend (no full app boot / no
|
|
13
|
+
# DEEPSEEK) and an env-only caller can skip the store checks entirely.
|
|
14
|
+
class Doctor
|
|
15
|
+
# severity: :ok (green) | :info | :warn | :error. `fix` = a 0-arg proc or nil.
|
|
16
|
+
Finding = Data.define(:check, :severity, :message, :fix) do
|
|
17
|
+
def fixable? = !fix.nil?
|
|
18
|
+
def ok? = severity == :ok
|
|
19
|
+
def error? = severity == :error
|
|
20
|
+
def to_h = { "check" => check, "severity" => severity.to_s, "message" => message, "fixable" => fixable? }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The outcome of a run: the findings + convenience rollups + renderers.
|
|
24
|
+
Report = Data.define(:findings) do
|
|
25
|
+
def ok? = errors.empty?
|
|
26
|
+
def errors = findings.select(&:error?)
|
|
27
|
+
def warnings = findings.select { |f| f.severity == :warn }
|
|
28
|
+
def fixable = findings.select(&:fixable?)
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{ "ok" => ok?, "counts" => counts, "findings" => findings.map(&:to_h) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def counts
|
|
35
|
+
findings.each_with_object(Hash.new(0)) { |f, acc| acc[f.severity.to_s] += 1 }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Human report. `color` wraps severities in ANSI when the sink is a TTY.
|
|
39
|
+
def to_s(color: false)
|
|
40
|
+
lines = findings.map { |f| "#{glyph(f.severity, color)} [#{f.check}] #{f.message}#{' (fixable)' if f.fixable?}" }
|
|
41
|
+
summary =
|
|
42
|
+
if ok? && warnings.empty? then "config OK"
|
|
43
|
+
elsif ok? then "config OK — #{warnings.length} warning(s)"
|
|
44
|
+
else "#{errors.length} error(s), #{warnings.length} warning(s)"
|
|
45
|
+
end
|
|
46
|
+
(lines + ["", summary]).join("\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def glyph(severity, color)
|
|
52
|
+
sym = { ok: "✓", info: "•", warn: "!", error: "✗" }.fetch(severity, "•")
|
|
53
|
+
return sym unless color
|
|
54
|
+
|
|
55
|
+
code = { ok: 32, info: 36, warn: 33, error: 31 }.fetch(severity, 0)
|
|
56
|
+
"\e[#{code}m#{sym}\e[0m"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def initialize(env: ENV, settings_store: nil, llm_provider_store: nil, tool_store: nil,
|
|
61
|
+
agent_file_store: nil, backend: nil, extra_env_specs: [])
|
|
62
|
+
@env = env
|
|
63
|
+
@settings_store = settings_store
|
|
64
|
+
@llm_provider_store = llm_provider_store
|
|
65
|
+
@tool_store = tool_store
|
|
66
|
+
@agent_file_store = agent_file_store
|
|
67
|
+
@backend = backend
|
|
68
|
+
@extra_env_specs = extra_env_specs
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# -> Report. Never raises (a broken check degrades to an :error Finding).
|
|
72
|
+
def run
|
|
73
|
+
Report.new(findings: checks.flat_map { |check| safe(check) }.freeze)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Applies every fixable finding, then re-diagnoses. -> [Report before, Report after].
|
|
77
|
+
def fix!
|
|
78
|
+
before = run
|
|
79
|
+
before.fixable.map(&:fix).each(&:call)
|
|
80
|
+
[before, run]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def checks = %i[check_env check_settings_schema check_default_model check_db check_llm_provider
|
|
86
|
+
check_admin_token check_data_tools check_prompt_files check_relay_channel
|
|
87
|
+
check_web_widget]
|
|
88
|
+
|
|
89
|
+
def safe(check)
|
|
90
|
+
Array(send(check))
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
id = check.to_s.sub(/^check_/, "").tr("_", "-")
|
|
93
|
+
[Finding.new(check: id, severity: :error, message: "check crashed: #{e.class}: #{e.message}", fix: nil)]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# -- checks --------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def check_env
|
|
99
|
+
findings = Insika::EnvSchema.validate(@env, extra: @extra_env_specs)
|
|
100
|
+
return [ok("env", "environment: no unknown/invalid keys in owned namespaces")] if findings.empty?
|
|
101
|
+
|
|
102
|
+
findings.map { |f| Finding.new(check: "env", severity: f.severity, message: f.message, fix: nil) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def check_settings_schema
|
|
106
|
+
return [] unless @settings_store
|
|
107
|
+
|
|
108
|
+
pending = @settings_store.pending_migrations
|
|
109
|
+
return [ok("settings-schema", "settings schema at v#{Insika::SettingsStore::SCHEMA_VERSION}")] if pending.empty?
|
|
110
|
+
|
|
111
|
+
[Finding.new(check: "settings-schema", severity: :warn,
|
|
112
|
+
message: "settings schema at v#{@settings_store.stored_schema_version}, pending migration(s) #{pending.inspect} to v#{Insika::SettingsStore::SCHEMA_VERSION}",
|
|
113
|
+
fix: -> { @settings_store.migrate! })]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def check_default_model
|
|
117
|
+
return [] unless @settings_store
|
|
118
|
+
|
|
119
|
+
settings = @settings_store.get
|
|
120
|
+
return [ok("default-model", "platform default model: #{settings['default_model']}")] if Insika::Coercion.present?(settings["default_model"])
|
|
121
|
+
|
|
122
|
+
seed = seed_model
|
|
123
|
+
fix = seed && -> { @settings_store.update("default_model" => seed) }
|
|
124
|
+
msg = "no platform default_model set (a model-less agent has nothing to resolve; set one in Studio > Settings)"
|
|
125
|
+
msg += " — fixable from #{seed}" if seed
|
|
126
|
+
[Finding.new(check: "default-model", severity: :warn, message: msg, fix: fix)]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def check_db
|
|
130
|
+
unless @backend.is_a?(Insika::Stores::SQLite)
|
|
131
|
+
return [Finding.new(check: "db", severity: :info,
|
|
132
|
+
message: "ephemeral backend (INSIKA_DB unset) — config and state do NOT survive a restart", fix: nil)]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
@backend.get("__doctor__", "probe") # a read round-trips the handle; raises if the file is broken
|
|
136
|
+
[ok("db", "durable backend: SQLite (readable)")]
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
[Finding.new(check: "db", severity: :error, message: "SQLite backend not usable: #{e.class}: #{e.message}", fix: nil)]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def check_llm_provider
|
|
142
|
+
return [] unless @llm_provider_store
|
|
143
|
+
|
|
144
|
+
configured = !Array(@llm_provider_store.apis).empty? || Insika::Coercion.present?(@env["DEEPSEEK_API_KEY"])
|
|
145
|
+
return [ok("llm-provider", "LLM provider configured")] if configured
|
|
146
|
+
|
|
147
|
+
[Finding.new(check: "llm-provider", severity: :warn,
|
|
148
|
+
message: "no LLM provider configured (set DEEPSEEK_API_KEY or add one in Studio > LLM providers) — turns will fail", fix: nil)]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def check_admin_token
|
|
152
|
+
return [ok("admin-token", "ADMIN_TOKEN set")] if Insika::Coercion.present?(@env["ADMIN_TOKEN"])
|
|
153
|
+
|
|
154
|
+
[Finding.new(check: "admin-token", severity: :warn,
|
|
155
|
+
message: "ADMIN_TOKEN unset — /studio is fail-closed (login denied) and the gateway has no fallback token", fix: nil)]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# A half-configured relay is the silent kind of broken (RFC-0011 §6): with only
|
|
159
|
+
# the deliver URL set, nothing is mounted and every inbound POST 404s; with only
|
|
160
|
+
# the token, the engine accepts turns it can never answer and the customer waits
|
|
161
|
+
# forever on a reply that is sitting in the outbox. Both halves or neither.
|
|
162
|
+
def check_relay_channel
|
|
163
|
+
token = Insika::Coercion.present?(@env["INSIKA_RELAY_TOKEN"])
|
|
164
|
+
url = Insika::Coercion.present?(@env["INSIKA_RELAY_DELIVER_URL"])
|
|
165
|
+
return [] unless token || url
|
|
166
|
+
|
|
167
|
+
if token && url
|
|
168
|
+
[ok("relay-channel", "relay channel mounted at /channels/relay/events")]
|
|
169
|
+
elsif token
|
|
170
|
+
[Finding.new(check: "relay-channel", severity: :warn,
|
|
171
|
+
message: "INSIKA_RELAY_TOKEN set without INSIKA_RELAY_DELIVER_URL — inbound is accepted but no reply can be delivered", fix: nil)]
|
|
172
|
+
else
|
|
173
|
+
[Finding.new(check: "relay-channel", severity: :warn,
|
|
174
|
+
message: "INSIKA_RELAY_DELIVER_URL set without INSIKA_RELAY_TOKEN — the relay channel is NOT mounted (the token is the switch)", fix: nil)]
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The widget is the one PUBLIC channel, so its misconfigurations are the ones that
|
|
179
|
+
# cost money rather than just failing (RFC-0011 §5.3/§5.4). Three of them, in the
|
|
180
|
+
# order they bite: half the switch set (mounted nowhere, or mounted addressing
|
|
181
|
+
# nothing), and a mount with no chat rate limit anywhere — which the channel
|
|
182
|
+
# refuses with a 503 rather than opening, but which reads to an operator as "the
|
|
183
|
+
# widget is broken" unless something says why.
|
|
184
|
+
def check_web_widget
|
|
185
|
+
origins = Insika::Coercion.present?(@env["INSIKA_WIDGET_ORIGINS"])
|
|
186
|
+
agents = Insika::Coercion.present?(@env["INSIKA_WIDGET_AGENTS"])
|
|
187
|
+
return [] unless origins || agents
|
|
188
|
+
|
|
189
|
+
unless origins && agents
|
|
190
|
+
missing = origins ? "INSIKA_WIDGET_AGENTS" : "INSIKA_WIDGET_ORIGINS"
|
|
191
|
+
return [Finding.new(check: "web-widget", severity: :warn,
|
|
192
|
+
message: "#{missing} unset — the web widget is NOT mounted (both allowlists are the switch)", fix: nil)]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
return [ok("web-widget", "web widget mounted at /channels/web (origins + agents allowlisted, rate limit configured)")] if platform_chat_rate_limit
|
|
196
|
+
|
|
197
|
+
# No platform default. A per-agent `limits.chat_rate_limit` still satisfies the
|
|
198
|
+
# gate, and the profiles are not readable from here, so this is a warning and
|
|
199
|
+
# not an error — but it is the likeliest reason a widget answers 503.
|
|
200
|
+
[Finding.new(check: "web-widget", severity: :warn,
|
|
201
|
+
message: "no platform edge.chat_rate_limit — the widget answers 503 unless EVERY agent in " \
|
|
202
|
+
"INSIKA_WIDGET_AGENTS sets limits.chat_rate_limit (a public channel with no ceiling is not served)", fix: nil)]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def platform_chat_rate_limit
|
|
206
|
+
value = ((@settings_store&.get || {})["edge"] || {})["chat_rate_limit"]
|
|
207
|
+
value.to_i.positive?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A stored data-tool whose definition no longer builds is INVISIBLE at runtime: the
|
|
211
|
+
# overlay warns to stderr and drops it, so the agent simply stops seeing the tool.
|
|
212
|
+
# This check is that drop's only report. The one legacy case is auto-fixable: a
|
|
213
|
+
# flat param typed bare `array`, which used to mean "list of strings" — the fix
|
|
214
|
+
# writes that meaning down as `array:string` and changes nothing at runtime.
|
|
215
|
+
# A prompt file holding the Ruby #inspect of a Hash instead of markdown. The write
|
|
216
|
+
# path refuses this now, but a deployment corrupted BEFORE that guard keeps serving
|
|
217
|
+
# the mangled prompt on every turn, and nothing else would ever say so: the file is
|
|
218
|
+
# present, non-empty, and the agent answers — worse than a crash. Found on the pilot
|
|
219
|
+
# by an `insika refine` report, three weeks after the fact.
|
|
220
|
+
def check_prompt_files
|
|
221
|
+
return [] unless @agent_file_store
|
|
222
|
+
|
|
223
|
+
agents = @agent_file_store.agents
|
|
224
|
+
wrapped = agents.flat_map do |agent|
|
|
225
|
+
@agent_file_store.list(agent).filter_map do |name|
|
|
226
|
+
next unless wrapped_content?(@agent_file_store.read(agent, name))
|
|
227
|
+
|
|
228
|
+
Finding.new(check: "prompt-files", severity: :error, fix: nil,
|
|
229
|
+
message: "agent '#{agent}' file '#{name}' holds a serialized object, not text — " \
|
|
230
|
+
"the model receives `{\"content\" => …}` on one line, escapes and all. " \
|
|
231
|
+
"Recover the markdown from inside the wrapper and write it back.")
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
return wrapped if wrapped.any?
|
|
235
|
+
|
|
236
|
+
total = agents.sum { |a| @agent_file_store.list(a).length }
|
|
237
|
+
[ok("prompt-files", "#{total} prompt file(s) across #{agents.length} agent(s): all text")]
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Cheap and specific: Ruby's inspect of a Hash whose first key is a string. A real
|
|
241
|
+
# prompt does not open with `{"…" =>`.
|
|
242
|
+
def wrapped_content?(content) = /\A\s*\{\s*"[^"]+"\s*=>/.match?(content.to_s)
|
|
243
|
+
|
|
244
|
+
def check_data_tools
|
|
245
|
+
return [] unless @tool_store
|
|
246
|
+
|
|
247
|
+
broken = @tool_store.all_raw.filter_map { |raw| broken_tool(raw) }
|
|
248
|
+
total = @tool_store.names.length
|
|
249
|
+
return [ok("data-tools", "#{total} data tool(s): every definition valid")] if broken.empty?
|
|
250
|
+
|
|
251
|
+
broken
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def broken_tool(raw)
|
|
255
|
+
Insika::ToolDefinition.from_h(raw)
|
|
256
|
+
nil
|
|
257
|
+
rescue Insika::ValidationError => e
|
|
258
|
+
name = raw.is_a?(Hash) ? raw["name"].to_s : ""
|
|
259
|
+
fix = array_sugar_fix(raw)
|
|
260
|
+
message = "tool '#{name}' is dropped from the catalog: #{e.message}"
|
|
261
|
+
# The autofix preserves the OLD behaviour, which is not always the INTENDED one:
|
|
262
|
+
# a list of objects has been reaching the model declared as a list of strings.
|
|
263
|
+
# Say so, so nobody reads a green doctor as "this tool is right".
|
|
264
|
+
message += " — the fix only writes down what it has been sending (a list of strings); " \
|
|
265
|
+
"if the API expects objects, paste the real JSON Schema in Studio instead" if fix
|
|
266
|
+
Finding.new(check: "data-tools", severity: :error, fix: fix, message: message)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# A fix ONLY when spelling the legacy bare `array` explicitly is enough to make the
|
|
270
|
+
# definition build — never a guess at a broken definition we don't understand.
|
|
271
|
+
def array_sugar_fix(raw)
|
|
272
|
+
params = raw.is_a?(Hash) ? raw["parameters"] : nil
|
|
273
|
+
return nil unless params.is_a?(Array) && params.any? { |p| p.is_a?(Hash) && p["type"].to_s == "array" }
|
|
274
|
+
|
|
275
|
+
candidate = raw.merge("parameters" => params.map do |p|
|
|
276
|
+
p.is_a?(Hash) && p["type"].to_s == "array" ? p.merge("type" => "array:string") : p
|
|
277
|
+
end)
|
|
278
|
+
Insika::ToolDefinition.from_h(candidate)
|
|
279
|
+
-> { @tool_store.write(candidate) }
|
|
280
|
+
rescue Insika::ValidationError
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# -- helpers -------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
def ok(check, message) = Finding.new(check: check, severity: :ok, message: message, fix: nil)
|
|
287
|
+
|
|
288
|
+
# A model to seed the platform default from: DEEPSEEK_MODEL env, else the first
|
|
289
|
+
# configured provider's default. nil when nothing to seed from.
|
|
290
|
+
def seed_model
|
|
291
|
+
Insika::Coercion.presence(@env["DEEPSEEK_MODEL"])
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module DSL
|
|
5
|
+
# The result of `Insika.agent("id") { … }`. Holds the generated Pack and knows
|
|
6
|
+
# how to run it — but stays gem-free until you actually chat/serve/introspect:
|
|
7
|
+
# the runtime (ruby_llm + the HTTP server) is required lazily inside Runtime.
|
|
8
|
+
#
|
|
9
|
+
# Every runtime path goes through the SAME config-over-code import: the pack is
|
|
10
|
+
# imported (create_agent + write_agent_file/skill/data_tool) into a StoredProfileSource,
|
|
11
|
+
# exactly like any other pack. That is what makes `#profile` identical to a
|
|
12
|
+
# hand-written equivalent pack (the parity spec) — there is only one path.
|
|
13
|
+
class Definition
|
|
14
|
+
attr_reader :pack, :runtime_options
|
|
15
|
+
|
|
16
|
+
def initialize(pack:, runtime: {})
|
|
17
|
+
@pack = pack
|
|
18
|
+
@runtime_options = runtime
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def id = pack.config[:id].to_s
|
|
22
|
+
|
|
23
|
+
# The generated portable artifact — hand it to any PackImporter and you get
|
|
24
|
+
# this same agent. This is the DSL's real output.
|
|
25
|
+
def to_pack = @pack
|
|
26
|
+
|
|
27
|
+
# The AgentProfile the engine runs, read back from the store after import —
|
|
28
|
+
# so it is byte-for-byte what a hand-written equivalent pack produces.
|
|
29
|
+
def profile = runtime.profile
|
|
30
|
+
|
|
31
|
+
# One turn, in-process, returns the assistant's text. `session:` threads
|
|
32
|
+
# multi-turn memory (a session is created on first use); omit it for a
|
|
33
|
+
# stateless one-shot. Raises on a failed/cancelled turn.
|
|
34
|
+
def reply(message, session: nil, timeout: nil)
|
|
35
|
+
runtime.chat(message, session_id: session, timeout: timeout)
|
|
36
|
+
end
|
|
37
|
+
alias_method :chat, :reply
|
|
38
|
+
|
|
39
|
+
# Boot the control UI (/studio) + the drop-in API (/v1) — the "1 command"
|
|
40
|
+
# of the quickstart. Blocks (runs the reactor) until interrupted.
|
|
41
|
+
def serve(port: 9292, host: "localhost", token: nil, **opts)
|
|
42
|
+
runtime.serve(port: port, host: host, token: token, **opts)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The live runtime graph (Executor + bus + stores) with the pack imported.
|
|
46
|
+
# Memoized: chat/serve/profile share one graph. Pulls in ruby_llm here.
|
|
47
|
+
def runtime
|
|
48
|
+
@runtime ||= begin
|
|
49
|
+
require_relative "runtime"
|
|
50
|
+
Runtime.new(self)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|