insika 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<%# Tools — drill-down (T2): [data tools + agents] master | agent tool matrix.
|
|
2
|
+
@sel_agent (from ?a=) drives the detail; data tools link to their editor page. %>
|
|
3
|
+
<div class="drill" data-controller="list-filter">
|
|
4
|
+
<aside class="drill-pane drill-master">
|
|
5
|
+
<div class="drill-pane-head">
|
|
6
|
+
<div class="title"><strong>Tools <span class="count"><%= @tools.size %></span></strong></div>
|
|
7
|
+
<a class="btn primary btn-sm" href="/studio/tools/def/new">+ data tool</a>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="drill-search">
|
|
10
|
+
<input type="search" class="filter-input" placeholder="Filter agents…" aria-label="Filter agents"
|
|
11
|
+
data-list-filter-target="query" data-action="input->list-filter#filter keydown->list-filter#clear">
|
|
12
|
+
</div>
|
|
13
|
+
<nav class="drill-pane-body drill-list" aria-label="tools and agents">
|
|
14
|
+
<% unless @data_tool_names.empty? %>
|
|
15
|
+
<div class="drill-group-label">Data tools · HTTP, editable</div>
|
|
16
|
+
<% @data_tool_names.sort.each do |name| %>
|
|
17
|
+
<% dropped = @dropped_tool_names.include?(name) %>
|
|
18
|
+
<a class="drill-item drill-item-sm" href="/studio/tools/def/<%= Rack::Utils.escape(name) %>"
|
|
19
|
+
<%== %( title="its definition is invalid — no agent can call it until it is fixed") if dropped %>>
|
|
20
|
+
<div class="drill-item-name"><span class="txt"><%= name %></span><span class="pill plain">data</span><% if dropped %> <span class="pill locked">dropped</span><% end %></div>
|
|
21
|
+
</a>
|
|
22
|
+
<% end %>
|
|
23
|
+
<% end %>
|
|
24
|
+
<div class="drill-group-label">Agents · allow / deny</div>
|
|
25
|
+
<% @agents.each do |a| %>
|
|
26
|
+
<a class="drill-item<%== ' active' if @sel_agent && @sel_agent.id == a.id %>"
|
|
27
|
+
href="/studio/tools?a=<%= Rack::Utils.escape(a.id) %>"
|
|
28
|
+
data-list-filter-target="item" data-filter-text="<%= a.id %>">
|
|
29
|
+
<div class="drill-item-name"><span class="txt"><%= a.id %></span>
|
|
30
|
+
<span class="pill plain"><%= a.tools_allow.nil? ? "all" : "#{tools_on_count(a, @tools)}/#{@tools.size}" %></span>
|
|
31
|
+
</div>
|
|
32
|
+
</a>
|
|
33
|
+
<% end %>
|
|
34
|
+
<div class="empty" data-list-filter-target="empty" hidden>No agents match your filter.</div>
|
|
35
|
+
</nav>
|
|
36
|
+
</aside>
|
|
37
|
+
|
|
38
|
+
<section class="drill-pane drill-detail">
|
|
39
|
+
<% if @sel_agent.nil? %>
|
|
40
|
+
<div class="drill-empty">
|
|
41
|
+
<div>
|
|
42
|
+
<h2>Tools</h2>
|
|
43
|
+
<p>Pick an agent to set which tools it can call — or edit a data tool on the left. <strong>all</strong> = open allowlist (every tool flows through); a <span class="pill locked">locked</span> tool is on the denylist (deny always wins).</p>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<% else %>
|
|
47
|
+
<% a = @sel_agent %>
|
|
48
|
+
<div class="drill-pane-head">
|
|
49
|
+
<div class="title">
|
|
50
|
+
<nav class="crumbs"><a href="/studio/tools">tools</a> / <span><%= a.id %></span></nav>
|
|
51
|
+
<strong class="mono"><%= a.id %></strong>
|
|
52
|
+
</div>
|
|
53
|
+
<button type="submit" form="tools-form" class="btn primary" data-turbo-submits-with="Saving…">Save tools</button>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="drill-pane-body" data-controller="toggle-counter">
|
|
56
|
+
<div class="matrix-topline">
|
|
57
|
+
<span class="count-badge mono" data-toggle-counter-target="count"><%= a.tools_allow.nil? ? "all" : "#{tools_on_count(a, @tools)}/#{@tools.size} on" %></span>
|
|
58
|
+
<label class="switch" title="open allowlist — every tool flows through">
|
|
59
|
+
<input type="checkbox" name="all_tools" value="1" form="tools-form"<%== " checked" if a.tools_allow.nil? %>
|
|
60
|
+
data-toggle-counter-target="all" data-action="change->toggle-counter#update">
|
|
61
|
+
<span class="switch-track" aria-hidden="true"></span>
|
|
62
|
+
<span class="switch-label">all tools</span>
|
|
63
|
+
</label>
|
|
64
|
+
</div>
|
|
65
|
+
<p class="muted">Checked tools are allowed next turn. A <span class="pill locked">locked</span> tool is on the denylist — deny always wins, so it can't be granted here.</p>
|
|
66
|
+
<form id="tools-form" method="post" action="/studio/tools/<%= Rack::Utils.escape(a.id) %>">
|
|
67
|
+
<%== csrf_tag %>
|
|
68
|
+
<% if @tools.empty? %>
|
|
69
|
+
<p class="muted">No tools registered in the runtime.</p>
|
|
70
|
+
<% else %>
|
|
71
|
+
<div class="check-grid tool-grid<%== " is-open" if a.tools_allow.nil? %>" data-toggle-counter-target="grid">
|
|
72
|
+
<% @tools.each do |t| %>
|
|
73
|
+
<% denied = tool_denied_for?(a, t.name) %>
|
|
74
|
+
<label class="check tool-check<%== " locked" if denied %>" title="<%= denied ? "denied — deny wins" : t.description %>">
|
|
75
|
+
<input type="checkbox" name="tools[]" value="<%= t.name %>"
|
|
76
|
+
<%== " checked" if tool_allowed_for?(a, t.name) && !denied %>
|
|
77
|
+
<%== " disabled" if denied %>
|
|
78
|
+
data-toggle-counter-target="tool" data-locked="<%= denied %>"
|
|
79
|
+
data-action="change->toggle-counter#update">
|
|
80
|
+
<span class="mono"><%= t.name %></span><% if @data_tool_names.include?(t.name) %> <span class="pill plain">data</span><% end %><% if denied %> <span class="pill locked">locked</span><% end %>
|
|
81
|
+
</label>
|
|
82
|
+
<% end %>
|
|
83
|
+
</div>
|
|
84
|
+
<% end %>
|
|
85
|
+
</form>
|
|
86
|
+
</div>
|
|
87
|
+
<% end %>
|
|
88
|
+
</section>
|
|
89
|
+
</div>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Definition-time integrity of the subagent delegation graph (
|
|
5
|
+
# A pure function over `{id => [child_ids]}`: detects CYCLES and
|
|
6
|
+
# computes the max delegation DEPTH, raising a typed SubagentError so the
|
|
7
|
+
# authoring Command (CreateAgent/UpdateAgent) fails cleanly and boot refuses a
|
|
8
|
+
# bad static set. Cycle + bounded depth are exactly Flue's definition-time
|
|
9
|
+
# guarantee (`DelegationDepthExceededError` + anti-circular) — with the graph
|
|
10
|
+
# acyclic and depth <= cap, the runtime is provably bounded (the guard in
|
|
11
|
+
# Executor#run_subagent is only belt-and-suspenders for a graph that changed
|
|
12
|
+
# mid-run).
|
|
13
|
+
#
|
|
14
|
+
# UNKNOWN child refs are treated as LEAVES (no outgoing edges), NOT an error:
|
|
15
|
+
# dynamic authoring must not break by creation order — a not-yet-created child
|
|
16
|
+
# surfaces as a clean "not found" at runtime (run_subagent), not a boot failure.
|
|
17
|
+
module SubagentGraph
|
|
18
|
+
# Longest delegation chain allowed (root counts as depth 0; each spawn +1).
|
|
19
|
+
# Override with INSIKA_SUBAGENT_DEPTH_CAP.
|
|
20
|
+
DEFAULT_DEPTH_CAP = 5
|
|
21
|
+
|
|
22
|
+
# Max children a single `spawn_subagents` fan-out may run — also the concurrency
|
|
23
|
+
# bound (N concurrent LLM calls hit the provider rate limit + the per-agent token
|
|
24
|
+
# ceiling of). Override with INSIKA_SUBAGENT_FANOUT_CAP.
|
|
25
|
+
DEFAULT_FANOUT_CAP = 8
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def depth_cap
|
|
30
|
+
int_env("INSIKA_SUBAGENT_DEPTH_CAP", DEFAULT_DEPTH_CAP)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def fan_out_cap
|
|
34
|
+
int_env("INSIKA_SUBAGENT_FANOUT_CAP", DEFAULT_FANOUT_CAP)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def int_env(name, default)
|
|
38
|
+
raw = Insika::EnvSchema.read(name)
|
|
39
|
+
raw && !raw.strip.empty? ? Integer(raw) : default
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Validates the whole set. `profiles` is anything enumerable of profiles
|
|
43
|
+
# responding to #id and #subagents (an Array or a ProfileSource#all result),
|
|
44
|
+
# OR a plain `{id => [child_ids]}` Hash. Raises on the FIRST violation.
|
|
45
|
+
def validate!(profiles, cap: depth_cap)
|
|
46
|
+
map = to_map(profiles)
|
|
47
|
+
map.each_key { |root| check_from(root, map, cap) }
|
|
48
|
+
map
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Builds the {id => [child_ids]} adjacency map. Absent/nil subagents => [].
|
|
52
|
+
def to_map(profiles)
|
|
53
|
+
return normalize(profiles) if profiles.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
each_profile(profiles).each_with_object({}) do |p, acc|
|
|
56
|
+
acc[p.id.to_s] = Array(p.subagents).map(&:to_s)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# DFS from `root` tracking the recursion stack (cycle) and the longest path
|
|
61
|
+
# (depth). A ref to an id absent from `map` is a leaf.
|
|
62
|
+
def check_from(root, map, cap)
|
|
63
|
+
walk(root, map, cap, [], {})
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the max depth of the subtree rooted at `node`. `stack` is the
|
|
67
|
+
# current path (cycle detection); `memo` caches finished subtrees.
|
|
68
|
+
def walk(node, map, cap, stack, memo)
|
|
69
|
+
raise SubagentCycleError.new(cycle: stack + [node]) if stack.include?(node)
|
|
70
|
+
return memo[node] if memo.key?(node)
|
|
71
|
+
|
|
72
|
+
children = map[node] || [] # unknown ref => leaf
|
|
73
|
+
depth = if children.empty?
|
|
74
|
+
0
|
|
75
|
+
else
|
|
76
|
+
1 + children.map { |c| walk(c, map, cap, stack + [node], memo) }.max
|
|
77
|
+
end
|
|
78
|
+
raise SubagentDepthExceeded.new(depth: depth, cap: cap) if depth > cap
|
|
79
|
+
|
|
80
|
+
memo[node] = depth
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalize(hash)
|
|
84
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = Array(v).map(&:to_s) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Duck-typed enumeration: an Array of profiles, or a ProfileSource exposing
|
|
88
|
+
# #all. Anything else Enumerable is iterated as-is.
|
|
89
|
+
def each_profile(profiles)
|
|
90
|
+
return profiles if profiles.is_a?(Array)
|
|
91
|
+
return profiles.all if profiles.respond_to?(:all)
|
|
92
|
+
|
|
93
|
+
Array(profiles)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# GLOBAL system files. These are prompts/rules
|
|
7
|
+
# that apply to ALL agents in the deploy — the "house" above each BIA's
|
|
8
|
+
# individual identity. Unlike the AgentFileStore (per agent), here there is no
|
|
9
|
+
# tenant: one record per file in the ConfigStore (scope "system_files").
|
|
10
|
+
#
|
|
11
|
+
# Context::Providers::Prompt reads these files and injects them BEFORE the
|
|
12
|
+
# per-agent identity, for every turn. With no system files (empty store),
|
|
13
|
+
# the prompt is byte-for-byte the one from before (parity preserved) — the global
|
|
14
|
+
# injection only exists when the operator authors something here.
|
|
15
|
+
#
|
|
16
|
+
# Record per file:
|
|
17
|
+
# { "content" => str, "updated_at" => iso8601,
|
|
18
|
+
# "history" => [ { "content" => str, "at" => iso8601 }, ... ] }
|
|
19
|
+
#
|
|
20
|
+
# A write versions (same contract as AgentFileStore): the previous content
|
|
21
|
+
# goes into `history` (most recent first), capped at HISTORY_MAX; restoring
|
|
22
|
+
# is a new write (linear history, no destructive "time travel").
|
|
23
|
+
class SystemFileStore
|
|
24
|
+
SCOPE = "system_files"
|
|
25
|
+
HISTORY_MAX = 20
|
|
26
|
+
|
|
27
|
+
def initialize(config_store:)
|
|
28
|
+
@cs = config_store
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# -> String | nil (current content).
|
|
32
|
+
def read(filename)
|
|
33
|
+
entry(filename.to_s)&.fetch("content", nil)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# -> [String] file names, lexicographic order.
|
|
37
|
+
def list
|
|
38
|
+
@cs.keys(SCOPE).sort
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Writes (upsert). create_only: refuses to overwrite. Versions the previous one into
|
|
42
|
+
# history. -> Hash (the stored entry).
|
|
43
|
+
def write(filename, content, create_only: false)
|
|
44
|
+
name = filename.to_s
|
|
45
|
+
raise Insika::ValidationError, "file is required" if name.empty?
|
|
46
|
+
|
|
47
|
+
current = entry(name)
|
|
48
|
+
if create_only && current
|
|
49
|
+
raise Insika::ValidationError, "system file '#{name}' already exists"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
built = build_entry(content.to_s, current)
|
|
53
|
+
@cs.put(SCOPE, name, built)
|
|
54
|
+
built
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# -> bool (did it exist?).
|
|
58
|
+
def delete(filename)
|
|
59
|
+
@cs.delete(SCOPE, filename.to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# -> [ { "content" =>, "at" => } ] older versions, most recent first.
|
|
63
|
+
def versions(filename)
|
|
64
|
+
entry(filename.to_s)&.fetch("history", []) || []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Restores version `index` from history as the current content (a new write).
|
|
68
|
+
# -> Hash (entry) | raises if index invalid / file nonexistent.
|
|
69
|
+
def restore(filename, index)
|
|
70
|
+
name = filename.to_s
|
|
71
|
+
current = entry(name)
|
|
72
|
+
raise Insika::NotFoundError, "system file '#{name}' not found" unless current
|
|
73
|
+
|
|
74
|
+
hist = current.fetch("history", [])
|
|
75
|
+
i = Integer(index)
|
|
76
|
+
raise Insika::ValidationError, "version #{index} does not exist" if i.negative? || i >= hist.length
|
|
77
|
+
|
|
78
|
+
write(name, hist[i]["content"])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def entry(name)
|
|
84
|
+
@cs.get(SCOPE, name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_entry(content, current)
|
|
88
|
+
history = current ? current.fetch("history", []) : []
|
|
89
|
+
if current
|
|
90
|
+
history = [{ "content" => current["content"], "at" => current["updated_at"] }] + history
|
|
91
|
+
history = history.first(HISTORY_MAX)
|
|
92
|
+
end
|
|
93
|
+
{ "content" => content, "updated_at" => Time.now.utc.iso8601, "history" => history }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/queue"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Actor model: one Async fiber per Task + a mailbox. The message enum is
|
|
8
|
+
# `cancel`/`user_message`/`approval`/`pause`/`resume`/`timeout`/`heartbeat`, with
|
|
9
|
+
# `await` as the cooperative SUSPENSION primitive. Cancellation/suspension only
|
|
10
|
+
# at stage boundaries — never in the middle of an operation.
|
|
11
|
+
class TaskActor
|
|
12
|
+
# `user_message` is posted by `Executor#steer_into_running` and
|
|
13
|
+
# consumed by `SteerInjector` at a tool-batch boundary. `pause`/`resume` (operator),
|
|
14
|
+
# `approval` (human-in-the-loop), `timeout`/`heartbeat`
|
|
15
|
+
# (watchdog/liveness, observation).
|
|
16
|
+
MESSAGES = %i[cancel user_message approval pause resume timeout heartbeat].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :task_id, :pending_user_messages, :heartbeats
|
|
19
|
+
# How many messages this run was ASKED to absorb, ever — including the ones
|
|
20
|
+
# already injected and cleared. It is what bounds steering (`steer_max_messages`),
|
|
21
|
+
# so it counts posts and never decreases.
|
|
22
|
+
attr_reader :user_messages_posted
|
|
23
|
+
|
|
24
|
+
def initialize(task_id:, parent: Async::Task.current)
|
|
25
|
+
@task_id = task_id
|
|
26
|
+
@parent = parent
|
|
27
|
+
@mailbox = Async::Queue.new
|
|
28
|
+
@pending_user_messages = []
|
|
29
|
+
@pause_requested = false
|
|
30
|
+
@heartbeats = 0
|
|
31
|
+
@user_messages_posted = 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Non-blocking. A message outside the enum is a caller bug.
|
|
35
|
+
def post(message, data = nil)
|
|
36
|
+
raise ArgumentError, "unknown message: #{message}" unless MESSAGES.include?(message)
|
|
37
|
+
|
|
38
|
+
@user_messages_posted += 1 if message == :user_message
|
|
39
|
+
@mailbox.enqueue([message, data])
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Runs the block on an Async fiber CHILD of the parent. Returns the Task.
|
|
44
|
+
def run(&turn_block)
|
|
45
|
+
@async_task = @parent.async { turn_block.call(self) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Drains the mailbox WITHOUT blocking (boundaries). `:cancel` raises (the top
|
|
49
|
+
# of the fiber maps to :cancelled). `:pause` arms the suspension (the Executor
|
|
50
|
+
# checks `pause_requested?`). Resolutions (`:resume`/`:approval`/`:timeout`)
|
|
51
|
+
# that arrive here WITH no pending suspension are DISCARDED (idempotent,
|
|
52
|
+
# no-op).
|
|
53
|
+
def drain!
|
|
54
|
+
until @mailbox.empty?
|
|
55
|
+
route_boundary(*@mailbox.dequeue)
|
|
56
|
+
end
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Did the operator request a pause? (consumed by the Executor; `await` clears
|
|
61
|
+
# the flag).
|
|
62
|
+
def pause_requested? = @pause_requested
|
|
63
|
+
|
|
64
|
+
# takes the steered messages and clears the buffer, WITHOUT
|
|
65
|
+
# observing anything else in the mailbox: whatever is not a `:user_message` is put
|
|
66
|
+
# back, in the order it arrived.
|
|
67
|
+
#
|
|
68
|
+
# Why not `drain!`: this runs INSIDE RubyLLM's tool loop (a `after_message`
|
|
69
|
+
# callback), and `drain!` raises on `:cancel`. Cancellation is only ever observed
|
|
70
|
+
# at the Executor's own stage boundaries — that is what keeps a tool batch one
|
|
71
|
+
# unit of work (and for the same rule under `turn_timeout`).
|
|
72
|
+
# Injecting a message must not quietly become a new place a turn can die.
|
|
73
|
+
def take_user_messages!
|
|
74
|
+
@mailbox.size.times do
|
|
75
|
+
message, data = @mailbox.dequeue
|
|
76
|
+
message == :user_message ? @pending_user_messages << data : @mailbox.enqueue([message, data])
|
|
77
|
+
end
|
|
78
|
+
taken = @pending_user_messages.dup
|
|
79
|
+
@pending_user_messages.clear
|
|
80
|
+
taken
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# BLOCKS the turn's fiber until a RESOLUTION (yields the reactor — no spin).
|
|
84
|
+
# Used by the Executor in :paused (waits for :resume) and by the ToolEnvelope
|
|
85
|
+
# in :waiting (waits for :approval). Returns [:resume, nil] or [:approval,
|
|
86
|
+
# data]. `:cancel` -> CancelledError; `:timeout` -> TimeoutError. A legitimate
|
|
87
|
+
# resolution only arrives WITH the fiber already blocked here (the operator
|
|
88
|
+
# only resumes/approves what is suspended), so it is consumed by this
|
|
89
|
+
# `dequeue` — there is no race requiring a buffer. Non-resolution messages
|
|
90
|
+
# received during the wait are ABSORBED without changing the suspension state
|
|
91
|
+
# (a redundant :pause does not re-arm the pause).
|
|
92
|
+
def await(reason:)
|
|
93
|
+
@pause_requested = false # the pause/wait is being handled now
|
|
94
|
+
loop do
|
|
95
|
+
message, data = @mailbox.dequeue
|
|
96
|
+
case message
|
|
97
|
+
when :cancel then raise CancelledError, "task #{@task_id} cancelled"
|
|
98
|
+
when :timeout then raise Insika::TimeoutError.new("wait (#{reason}) exceeded", stage: data || reason)
|
|
99
|
+
when :resume, :approval then return [message, data]
|
|
100
|
+
when :heartbeat then @heartbeats += 1
|
|
101
|
+
when :user_message then @pending_user_messages << data
|
|
102
|
+
# :pause during the wait: already suspended, ignore (does not re-arm pause_requested)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# specs/boot await the fiber's completion.
|
|
108
|
+
def wait = @async_task&.wait
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Routing of boundary messages (non-blocking). Orphan resolutions
|
|
113
|
+
# (`:resume`/`:approval`/`:timeout` with no pending suspension) are DISCARDED —
|
|
114
|
+
# NEVER buffered: a stored resolution would wrongly resolve a FUTURE `await`
|
|
115
|
+
# (auto-resume/auto-approve/auto-timeout of a suspension the operator did not
|
|
116
|
+
# resolve). Legitimate resolutions arrive with the fiber already in `await`
|
|
117
|
+
# (consumed there), so discarding here is safe and idempotent.
|
|
118
|
+
def route_boundary(message, data)
|
|
119
|
+
case message
|
|
120
|
+
when :cancel then raise CancelledError, "task #{@task_id} cancelled"
|
|
121
|
+
when :pause then @pause_requested = true
|
|
122
|
+
when :user_message then @pending_user_messages << data
|
|
123
|
+
when :heartbeat then @heartbeats += 1
|
|
124
|
+
when :resume, :approval, :timeout then nil # orphan: discard (see comment)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Domain store for tasks. Persists Tasks over an
|
|
8
|
+
# injected Insika::Store, with the STATE MACHINE validated here: the store is
|
|
9
|
+
# the only place status is written, so the
|
|
10
|
+
# invariants live where the writes live. An invalid transition is a bug and raises
|
|
11
|
+
# ArgumentError loud and early — this is how logical races are
|
|
12
|
+
# detected without a lock.
|
|
13
|
+
#
|
|
14
|
+
# Each Execution is ONE attempt; retry/resume opens a new entry, never
|
|
15
|
+
# overwrites.
|
|
16
|
+
class TaskStore
|
|
17
|
+
include Coercion
|
|
18
|
+
|
|
19
|
+
SCOPE = "tasks"
|
|
20
|
+
KEY_PREFIX = "task:"
|
|
21
|
+
|
|
22
|
+
STATUSES = %i[queued running waiting paused completed failed cancelled].freeze
|
|
23
|
+
|
|
24
|
+
# Valid transitions — anything outside this is a bug -> ArgumentError.
|
|
25
|
+
TRANSITIONS = {
|
|
26
|
+
# queued -> failed: a turn queued in the SessionActor may fail
|
|
27
|
+
# at STARTUP (spawn error before the fiber) without ever running.
|
|
28
|
+
queued: %i[running cancelled failed],
|
|
29
|
+
running: %i[waiting paused completed failed cancelled],
|
|
30
|
+
waiting: %i[running cancelled failed],
|
|
31
|
+
paused: %i[running cancelled],
|
|
32
|
+
completed: [], failed: [], cancelled: [] # terminal
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
Task = Data.define(:id, :status, :command, :session_id, :executions,
|
|
36
|
+
:mailbox_state, :created_at, :updated_at)
|
|
37
|
+
Execution = Data.define(:attempt, :started_at, :finished_at, :outcome, :error)
|
|
38
|
+
|
|
39
|
+
def initialize(store:)
|
|
40
|
+
@store = store
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# -> Task (status :queued). command: Hash ({type:, payload:, meta:}) or
|
|
44
|
+
# any object that responds to to_h (e.g. Insika::Command).
|
|
45
|
+
# ArgumentError if the id already exists. `at` (ISO8601) is injectable for
|
|
46
|
+
# deterministic tests — the timestamp has SECOND precision, so two tasks created
|
|
47
|
+
# in the same second are indistinguishable by time to any reader ordering by it
|
|
48
|
+
# (same rationale as MemoryStore#add_note).
|
|
49
|
+
def create(command:, session_id: nil, id: SecureRandom.uuid, at: nil)
|
|
50
|
+
key = key_for(id)
|
|
51
|
+
raise ArgumentError, "task already exists: #{id}" unless @store.get(SCOPE, key).nil?
|
|
52
|
+
|
|
53
|
+
now = at || timestamp
|
|
54
|
+
record = {
|
|
55
|
+
"id" => id.to_s,
|
|
56
|
+
"status" => "queued",
|
|
57
|
+
"command" => deep_stringify(command.respond_to?(:to_h) ? command.to_h : command),
|
|
58
|
+
"session_id" => session_id&.to_s,
|
|
59
|
+
"executions" => [],
|
|
60
|
+
"mailbox_state" => { "pending" => [] },
|
|
61
|
+
"created_at" => now,
|
|
62
|
+
"updated_at" => now
|
|
63
|
+
}
|
|
64
|
+
@store.set(SCOPE, key, record)
|
|
65
|
+
to_task(record)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# -> Task | nil
|
|
69
|
+
def find(id)
|
|
70
|
+
record = @store.get(SCOPE, key_for(id))
|
|
71
|
+
record && to_task(record)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# -> Task; validates the state machine. NotFoundError if absent,
|
|
75
|
+
# ArgumentError for a status outside the enum or an invalid transition.
|
|
76
|
+
# If `error:` is provided AND there is an open Execution, it closes it in the same write
|
|
77
|
+
# (Recovery path).
|
|
78
|
+
#
|
|
79
|
+
# The read-check-write rides Store#transaction because this is also the
|
|
80
|
+
# dispatch CLAIM: two workers racing queued -> running serialize on the
|
|
81
|
+
# backend's lock, the loser re-reads :running and gets the loud
|
|
82
|
+
# ArgumentError instead of a second silent owner.
|
|
83
|
+
def transition(id, to:, error: nil)
|
|
84
|
+
target = to.to_sym
|
|
85
|
+
raise ArgumentError, "invalid status: #{to}" unless STATUSES.include?(target)
|
|
86
|
+
|
|
87
|
+
@store.transaction do
|
|
88
|
+
record = fetch!(id)
|
|
89
|
+
from = record["status"].to_sym
|
|
90
|
+
unless TRANSITIONS.fetch(from).include?(target)
|
|
91
|
+
raise ArgumentError, "invalid transition: #{from} -> #{target}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
close_open_execution(record, outcome: target.to_s, error: error) if error
|
|
95
|
+
record["status"] = target.to_s
|
|
96
|
+
record["updated_at"] = timestamp
|
|
97
|
+
@store.set(SCOPE, key_for(id), record)
|
|
98
|
+
to_task(record)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# -> Task; opens an Execution (attempt N+1). ArgumentError if one is already
|
|
103
|
+
# open (a double attempt is a bug — one owner per task). Append-only.
|
|
104
|
+
def begin_execution(id)
|
|
105
|
+
record = fetch!(id)
|
|
106
|
+
raise ArgumentError, "an open Execution already exists on task #{id}" if open_execution(record)
|
|
107
|
+
|
|
108
|
+
record["executions"] += [{
|
|
109
|
+
"attempt" => record["executions"].size + 1,
|
|
110
|
+
"started_at" => timestamp,
|
|
111
|
+
"finished_at" => nil,
|
|
112
|
+
"outcome" => nil,
|
|
113
|
+
"error" => nil
|
|
114
|
+
}]
|
|
115
|
+
record["updated_at"] = timestamp
|
|
116
|
+
@store.set(SCOPE, key_for(id), record)
|
|
117
|
+
to_task(record)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# -> Task; closes the current Execution. ArgumentError if none is open.
|
|
121
|
+
# Does NOT touch status (that is transition's job).
|
|
122
|
+
def finish_execution(id, outcome:)
|
|
123
|
+
record = fetch!(id)
|
|
124
|
+
open = open_execution(record)
|
|
125
|
+
raise ArgumentError, "no open Execution on task #{id}" if open.nil?
|
|
126
|
+
|
|
127
|
+
open["finished_at"] = timestamp
|
|
128
|
+
open["outcome"] = outcome.to_s
|
|
129
|
+
record["updated_at"] = timestamp
|
|
130
|
+
@store.set(SCOPE, key_for(id), record)
|
|
131
|
+
to_task(record)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# (`collect`): appends a fragment to a task's message while it is
|
|
135
|
+
# still waiting at the door. -> Task.
|
|
136
|
+
#
|
|
137
|
+
# ONLY on :queued, and that guard is the whole safety of the feature: once a
|
|
138
|
+
# turn is :running its input has been read into the Chat, seeded into the
|
|
139
|
+
# context and possibly sent to the provider — rewriting it there would mean the
|
|
140
|
+
# transcript disagrees with what the model actually saw. ArgumentError on any
|
|
141
|
+
# other status, so a lost race fails loudly instead of corrupting a turn.
|
|
142
|
+
def append_message(id, text, separator: "\n")
|
|
143
|
+
fragment = presence(text)
|
|
144
|
+
return to_task(fetch!(id)) if fragment.nil?
|
|
145
|
+
|
|
146
|
+
record = fetch!(id)
|
|
147
|
+
unless record["status"] == "queued"
|
|
148
|
+
raise ArgumentError, "task #{id} is #{record['status']}, not queued: its message is already in flight"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
payload = (record["command"]["payload"] ||= {})
|
|
152
|
+
current = presence(payload["message"])
|
|
153
|
+
payload["message"] = current ? "#{current}#{separator}#{fragment}" : fragment
|
|
154
|
+
record["updated_at"] = timestamp
|
|
155
|
+
@store.set(SCOPE, key_for(id), record)
|
|
156
|
+
to_task(record)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# -> [Task] with one of the given statuses. O(n) scan at boot;
|
|
160
|
+
# acceptable (one node, local SQLite).
|
|
161
|
+
def with_status(*statuses)
|
|
162
|
+
wanted = statuses.flatten
|
|
163
|
+
@store.list(SCOPE, KEY_PREFIX).filter_map do |key|
|
|
164
|
+
record = @store.get(SCOPE, key)
|
|
165
|
+
next if record.nil?
|
|
166
|
+
|
|
167
|
+
task = to_task(record)
|
|
168
|
+
task if wanted.include?(task.status)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Interrupted (crashed mid-turn): have a checkpoint -> resume.
|
|
173
|
+
def running_or_interrupted = with_status(:running, :waiting, :paused)
|
|
174
|
+
|
|
175
|
+
# Queued but never started (turn in the SessionActor queue at the
|
|
176
|
+
# crash) — no checkpoint; recovering = RUN from scratch (Recovery/ResumeTask).
|
|
177
|
+
def queued = with_status(:queued)
|
|
178
|
+
|
|
179
|
+
# -> enumerates ids without the "task:" prefix; without a block returns an Enumerator.
|
|
180
|
+
def each_id
|
|
181
|
+
return enum_for(:each_id) unless block_given?
|
|
182
|
+
|
|
183
|
+
@store.list(SCOPE, KEY_PREFIX).each do |key|
|
|
184
|
+
yield key.delete_prefix(KEY_PREFIX)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def key_for(id)
|
|
191
|
+
"#{KEY_PREFIX}#{id}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# NotFoundError if absent (nonexistent task -> 404). Backend StoreError
|
|
195
|
+
# propagates without re-wrapping.
|
|
196
|
+
def fetch!(id)
|
|
197
|
+
record = @store.get(SCOPE, key_for(id))
|
|
198
|
+
raise Insika::NotFoundError, "task not found: #{id}" if record.nil?
|
|
199
|
+
|
|
200
|
+
record
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The open Execution is the last one with finished_at nil (one owner per task, so
|
|
204
|
+
# there is at most one). Returns the raw Hash (mutable in-place for the RMW).
|
|
205
|
+
def open_execution(record)
|
|
206
|
+
last = record["executions"].last
|
|
207
|
+
last if last && last["finished_at"].nil?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def close_open_execution(record, outcome:, error:)
|
|
211
|
+
open = open_execution(record)
|
|
212
|
+
return if open.nil? # no open attempt: nowhere to record
|
|
213
|
+
|
|
214
|
+
open["finished_at"] = timestamp
|
|
215
|
+
open["outcome"] = outcome
|
|
216
|
+
open["error"] = deep_stringify(error)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Materializes Task from the raw Hash (type normalization at the edge):
|
|
220
|
+
# `status` comes back as a Symbol (domain enum, compared against
|
|
221
|
+
# STATUSES); `command`/`mailbox_state`/`error` stay as Hashes with string keys
|
|
222
|
+
# (they are data, not enums).
|
|
223
|
+
def to_task(record)
|
|
224
|
+
Task.new(
|
|
225
|
+
id: record["id"],
|
|
226
|
+
status: record["status"].to_sym,
|
|
227
|
+
command: record["command"],
|
|
228
|
+
session_id: record["session_id"],
|
|
229
|
+
executions: record["executions"].map { |e| to_execution(e) },
|
|
230
|
+
mailbox_state: record["mailbox_state"],
|
|
231
|
+
created_at: record["created_at"],
|
|
232
|
+
updated_at: record["updated_at"]
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def to_execution(hash)
|
|
237
|
+
Execution.new(
|
|
238
|
+
attempt: hash["attempt"],
|
|
239
|
+
started_at: hash["started_at"],
|
|
240
|
+
finished_at: hash["finished_at"],
|
|
241
|
+
outcome: hash["outcome"],
|
|
242
|
+
error: hash["error"]
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def timestamp
|
|
247
|
+
Time.now.utc.iso8601
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|