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.
Files changed (260) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +295 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +351 -0
  6. data/docs/AGENTS.md +494 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +100 -0
  11. data/docs/DEPLOY.md +334 -0
  12. data/docs/EMBEDDING.md +194 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +231 -0
  15. data/docs/OBSERVABILITY.md +365 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +362 -0
  22. data/docs/SKILLS.md +98 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +188 -0
  34. data/lib/insika/allowlist.rb +28 -0
  35. data/lib/insika/baseline_store.rb +74 -0
  36. data/lib/insika/capability/resolved_tool.rb +34 -0
  37. data/lib/insika/capability_registry.rb +112 -0
  38. data/lib/insika/channel_delivery.rb +150 -0
  39. data/lib/insika/channel_registry.rb +30 -0
  40. data/lib/insika/channels/relay.rb +178 -0
  41. data/lib/insika/channels/web/widget.js +283 -0
  42. data/lib/insika/channels/web.rb +211 -0
  43. data/lib/insika/chat_builder.rb +254 -0
  44. data/lib/insika/checkpoint.rb +13 -0
  45. data/lib/insika/checkpoint_store.rb +153 -0
  46. data/lib/insika/coercion.rb +50 -0
  47. data/lib/insika/command.rb +32 -0
  48. data/lib/insika/command_bus.rb +39 -0
  49. data/lib/insika/commands/agent_payload.rb +41 -0
  50. data/lib/insika/commands/approve_action.rb +46 -0
  51. data/lib/insika/commands/cancel_task.rb +33 -0
  52. data/lib/insika/commands/create_agent.rb +54 -0
  53. data/lib/insika/commands/create_session.rb +67 -0
  54. data/lib/insika/commands/delete_agent.rb +33 -0
  55. data/lib/insika/commands/delete_agent_file.rb +50 -0
  56. data/lib/insika/commands/delete_data_tool.rb +33 -0
  57. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  58. data/lib/insika/commands/delete_mcp.rb +30 -0
  59. data/lib/insika/commands/delete_system_file.rb +29 -0
  60. data/lib/insika/commands/gate_refinement.rb +245 -0
  61. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  62. data/lib/insika/commands/import_tools.rb +81 -0
  63. data/lib/insika/commands/memory_add_note.rb +32 -0
  64. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  65. data/lib/insika/commands/memory_put_fact.rb +35 -0
  66. data/lib/insika/commands/pause_task.rb +29 -0
  67. data/lib/insika/commands/resolve_refinement.rb +126 -0
  68. data/lib/insika/commands/restore_agent_file.rb +36 -0
  69. data/lib/insika/commands/restore_data_tool.rb +34 -0
  70. data/lib/insika/commands/restore_system_file.rb +31 -0
  71. data/lib/insika/commands/resume_task.rb +85 -0
  72. data/lib/insika/commands/run_refinement.rb +133 -0
  73. data/lib/insika/commands/send_message.rb +150 -0
  74. data/lib/insika/commands/set_agent_tools.rb +39 -0
  75. data/lib/insika/commands/set_skill_agents.rb +71 -0
  76. data/lib/insika/commands/trigger_workflow.rb +80 -0
  77. data/lib/insika/commands/update_agent.rb +49 -0
  78. data/lib/insika/commands/update_settings.rb +33 -0
  79. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  80. data/lib/insika/commands/upsert_mcp.rb +32 -0
  81. data/lib/insika/commands/write_agent_file.rb +57 -0
  82. data/lib/insika/commands/write_data_tool.rb +43 -0
  83. data/lib/insika/commands/write_golden.rb +58 -0
  84. data/lib/insika/commands/write_skill.rb +50 -0
  85. data/lib/insika/commands/write_system_file.rb +31 -0
  86. data/lib/insika/config_store.rb +85 -0
  87. data/lib/insika/context/builder.rb +166 -0
  88. data/lib/insika/context/catalog_provider.rb +23 -0
  89. data/lib/insika/context/fragment.rb +19 -0
  90. data/lib/insika/context/priority.rb +29 -0
  91. data/lib/insika/context/provider.rb +19 -0
  92. data/lib/insika/context/providers/memory.rb +60 -0
  93. data/lib/insika/context/providers/prompt.rb +105 -0
  94. data/lib/insika/context/providers/request.rb +32 -0
  95. data/lib/insika/context/providers/session.rb +108 -0
  96. data/lib/insika/context/providers/skill.rb +20 -0
  97. data/lib/insika/context/providers/tool_search.rb +20 -0
  98. data/lib/insika/delegation_store.rb +153 -0
  99. data/lib/insika/doctor.rb +294 -0
  100. data/lib/insika/dsl/definition.rb +55 -0
  101. data/lib/insika/dsl/runtime.rb +379 -0
  102. data/lib/insika/dsl/server_boot.rb +97 -0
  103. data/lib/insika/dsl/system.rb +93 -0
  104. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  105. data/lib/insika/dsl.rb +307 -0
  106. data/lib/insika/edge_limiter.rb +130 -0
  107. data/lib/insika/egress_guard.rb +75 -0
  108. data/lib/insika/env_schema.rb +246 -0
  109. data/lib/insika/errors.rb +145 -0
  110. data/lib/insika/evals/assertions.rb +247 -0
  111. data/lib/insika/evals/baseline.rb +69 -0
  112. data/lib/insika/evals/golden.rb +172 -0
  113. data/lib/insika/evals/judge.rb +225 -0
  114. data/lib/insika/evals/pairwise.rb +178 -0
  115. data/lib/insika/evals/report.rb +115 -0
  116. data/lib/insika/evals/runner.rb +141 -0
  117. data/lib/insika/evals/transport.rb +178 -0
  118. data/lib/insika/event.rb +18 -0
  119. data/lib/insika/event_stream.rb +114 -0
  120. data/lib/insika/executor.rb +1680 -0
  121. data/lib/insika/frontmatter.rb +42 -0
  122. data/lib/insika/golden_store.rb +145 -0
  123. data/lib/insika/hooks.rb +48 -0
  124. data/lib/insika/http_client.rb +63 -0
  125. data/lib/insika/inbound_log.rb +84 -0
  126. data/lib/insika/llm_configurator.rb +99 -0
  127. data/lib/insika/llm_provider_store.rb +83 -0
  128. data/lib/insika/mcp_http_client.rb +67 -0
  129. data/lib/insika/mcp_store.rb +115 -0
  130. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  131. data/lib/insika/memory_store.rb +93 -0
  132. data/lib/insika/message_origin.rb +76 -0
  133. data/lib/insika/middleware.rb +36 -0
  134. data/lib/insika/model_policy.rb +52 -0
  135. data/lib/insika/model_resolver.rb +176 -0
  136. data/lib/insika/model_selection.rb +114 -0
  137. data/lib/insika/onboarding.rb +208 -0
  138. data/lib/insika/outbox_store.rb +166 -0
  139. data/lib/insika/overlay_tool_registry.rb +103 -0
  140. data/lib/insika/pack.rb +102 -0
  141. data/lib/insika/pack_importer.rb +121 -0
  142. data/lib/insika/pending_action_store.rb +120 -0
  143. data/lib/insika/plugin/loader.rb +356 -0
  144. data/lib/insika/plugin.rb +35 -0
  145. data/lib/insika/policy/engine.rb +83 -0
  146. data/lib/insika/policy/policy.rb +120 -0
  147. data/lib/insika/policy_registry.rb +23 -0
  148. data/lib/insika/profile_source.rb +137 -0
  149. data/lib/insika/prompt_catalog.rb +61 -0
  150. data/lib/insika/queue_policy.rb +167 -0
  151. data/lib/insika/recovery.rb +127 -0
  152. data/lib/insika/refinement/candidate.rb +159 -0
  153. data/lib/insika/refinement/evidence_collector.rb +371 -0
  154. data/lib/insika/refinement/gate.rb +234 -0
  155. data/lib/insika/refinement/panel.rb +222 -0
  156. data/lib/insika/refinement/proposer.rb +262 -0
  157. data/lib/insika/refinement_store.rb +295 -0
  158. data/lib/insika/registry.rb +59 -0
  159. data/lib/insika/safety/config.rb +109 -0
  160. data/lib/insika/safety/detectors.rb +176 -0
  161. data/lib/insika/safety/factory.rb +102 -0
  162. data/lib/insika/safety/input_guardrail.rb +87 -0
  163. data/lib/insika/safety/moderator.rb +86 -0
  164. data/lib/insika/safety/output_filter.rb +79 -0
  165. data/lib/insika/safety/output_validator.rb +101 -0
  166. data/lib/insika/safety/safe_responses.rb +47 -0
  167. data/lib/insika/sandbox/boundary.rb +93 -0
  168. data/lib/insika/sandbox/docker.rb +74 -0
  169. data/lib/insika/sandbox/local.rb +33 -0
  170. data/lib/insika/sandbox/runner.rb +80 -0
  171. data/lib/insika/sandbox.rb +85 -0
  172. data/lib/insika/schema_guard.rb +147 -0
  173. data/lib/insika/secret_masking.rb +34 -0
  174. data/lib/insika/server/a2a/agent_card.rb +27 -0
  175. data/lib/insika/server/a2a/app.rb +112 -0
  176. data/lib/insika/server/a2a/client.rb +101 -0
  177. data/lib/insika/server/a2a/errors.rb +32 -0
  178. data/lib/insika/server/a2a/http.rb +42 -0
  179. data/lib/insika/server/a2a/message.rb +27 -0
  180. data/lib/insika/server/a2a/protocol.rb +45 -0
  181. data/lib/insika/server/a2a/remotes.rb +25 -0
  182. data/lib/insika/server/a2a/task_projection.rb +40 -0
  183. data/lib/insika/server/admin_auth.rb +29 -0
  184. data/lib/insika/server/app.rb +850 -0
  185. data/lib/insika/server/boot.rb +119 -0
  186. data/lib/insika/server/rack_app.rb +110 -0
  187. data/lib/insika/server/responses.rb +155 -0
  188. data/lib/insika/server/sse_body.rb +96 -0
  189. data/lib/insika/session_actor.rb +162 -0
  190. data/lib/insika/session_store.rb +143 -0
  191. data/lib/insika/settings_store.rb +154 -0
  192. data/lib/insika/shutdown.rb +125 -0
  193. data/lib/insika/skill_catalog.rb +113 -0
  194. data/lib/insika/skill_store.rb +79 -0
  195. data/lib/insika/steer_injector.rb +110 -0
  196. data/lib/insika/store.rb +52 -0
  197. data/lib/insika/stores/memory.rb +123 -0
  198. data/lib/insika/stores/sqlite.rb +183 -0
  199. data/lib/insika/studio/app.rb +1571 -0
  200. data/lib/insika/studio/assets/dist/application.css +1 -0
  201. data/lib/insika/studio/assets/dist/application.js +69 -0
  202. data/lib/insika/studio/forms.rb +340 -0
  203. data/lib/insika/studio/nav_icons.rb +31 -0
  204. data/lib/insika/studio/views/_message.erb +44 -0
  205. data/lib/insika/studio/views/agent_detail.erb +285 -0
  206. data/lib/insika/studio/views/agents.erb +63 -0
  207. data/lib/insika/studio/views/approvals.erb +41 -0
  208. data/lib/insika/studio/views/chats.erb +34 -0
  209. data/lib/insika/studio/views/evals.erb +83 -0
  210. data/lib/insika/studio/views/home.erb +72 -0
  211. data/lib/insika/studio/views/layout.erb +94 -0
  212. data/lib/insika/studio/views/login.erb +17 -0
  213. data/lib/insika/studio/views/mcp.erb +91 -0
  214. data/lib/insika/studio/views/not_found.erb +5 -0
  215. data/lib/insika/studio/views/playground.erb +47 -0
  216. data/lib/insika/studio/views/refinement.erb +234 -0
  217. data/lib/insika/studio/views/session.erb +62 -0
  218. data/lib/insika/studio/views/settings.erb +173 -0
  219. data/lib/insika/studio/views/skills.erb +86 -0
  220. data/lib/insika/studio/views/system_files.erb +65 -0
  221. data/lib/insika/studio/views/task.erb +105 -0
  222. data/lib/insika/studio/views/tasks.erb +33 -0
  223. data/lib/insika/studio/views/tool_edit.erb +107 -0
  224. data/lib/insika/studio/views/tools.erb +89 -0
  225. data/lib/insika/subagent_graph.rb +96 -0
  226. data/lib/insika/system_file_store.rb +96 -0
  227. data/lib/insika/task_actor.rb +128 -0
  228. data/lib/insika/task_store.rb +250 -0
  229. data/lib/insika/telemetry/pricing.rb +104 -0
  230. data/lib/insika/telemetry/recorder.rb +228 -0
  231. data/lib/insika/telemetry.rb +127 -0
  232. data/lib/insika/testing/store_contract.rb +270 -0
  233. data/lib/insika/token_estimator.rb +16 -0
  234. data/lib/insika/tool_assembly.rb +140 -0
  235. data/lib/insika/tool_catalog.rb +89 -0
  236. data/lib/insika/tool_definition.rb +518 -0
  237. data/lib/insika/tool_envelope.rb +140 -0
  238. data/lib/insika/tool_manifest.rb +218 -0
  239. data/lib/insika/tool_registry.rb +21 -0
  240. data/lib/insika/tool_store.rb +135 -0
  241. data/lib/insika/tool_trace_store.rb +92 -0
  242. data/lib/insika/tools/a2a_remote.rb +48 -0
  243. data/lib/insika/tools/agent_enum.rb +68 -0
  244. data/lib/insika/tools/concurrency.rb +54 -0
  245. data/lib/insika/tools/data_defined_tool.rb +220 -0
  246. data/lib/insika/tools/load_skill.rb +41 -0
  247. data/lib/insika/tools/remember.rb +53 -0
  248. data/lib/insika/tools/subagent.rb +75 -0
  249. data/lib/insika/tools/subagents.rb +77 -0
  250. data/lib/insika/tools/tool_search.rb +94 -0
  251. data/lib/insika/turn_output.rb +139 -0
  252. data/lib/insika/turn_state.rb +158 -0
  253. data/lib/insika/turn_timing.rb +56 -0
  254. data/lib/insika/usage_ledger.rb +47 -0
  255. data/lib/insika/version.rb +3 -1
  256. data/lib/insika/wiring/graph.rb +198 -0
  257. data/lib/insika/workflow.rb +185 -0
  258. data/lib/insika/workflow_registry.rb +33 -0
  259. data/lib/insika.rb +203 -4
  260. metadata +395 -8
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runner"
4
+
5
+ module Insika
6
+ module Sandbox
7
+ # Default exec provider: runs the command IN-PROCESS on the host, with the
8
+ # working directory pinned to the sandbox root. This is the "narrowest sandbox
9
+ # that supports the task" for trusted/local operation — cheap, no daemon, no
10
+ # image pull. It is NOT an isolation boundary for a shell (a command can still
11
+ # read absolute paths or `cd ..`); that is why shell tools stay approval-gated
12
+ # and why the `docker` provider exists for untrusted execution.
13
+ #
14
+ # The improvement over the prototype's raw `capture2e` is the real hard-kill
15
+ # timeout (via Runner): a hung command no longer holds the turn open until the
16
+ # OS returns.
17
+ class Local
18
+ # `-c` (non-login): a login shell would source ~/.bash_profile on every
19
+ # call, adding latency and letting host dotfiles mutate PATH/env under the
20
+ # command — surprising for a sandboxed tool.
21
+ def initialize(shell: "/bin/bash")
22
+ @shell = shell
23
+ end
24
+
25
+ def exec(command, root:, timeout:, max_output:)
26
+ Runner.run([@shell, "-c", command.to_s],
27
+ chdir: root, timeout: timeout, max_output: max_output)
28
+ end
29
+
30
+ def to_s = "local(#{@shell})"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../coercion"
5
+
6
+ module Insika
7
+ module Sandbox
8
+ # Structured result of a sandboxed command. `timed_out` distinguishes a
9
+ # deadline kill (exit_status nil) from a normal exit. `output` is the combined
10
+ # stdout+stderr, already clipped to the provider's cap.
11
+ Result = Data.define(:exit_status, :output, :timed_out) do
12
+ def timed_out? = timed_out
13
+ # Shape returned to the model by the bash tool (parity with the prototype).
14
+ def to_h = { exit_status: exit_status, output: output, timed_out: timed_out }
15
+ end
16
+
17
+ # Spawns a command with a REAL hard-kill timeout, shared by every exec
18
+ # provider. Unlike the prototype's `Open3.capture2e` (which blocks
19
+ # uninterruptibly — a hung command holds the fiber until the OS returns), this
20
+ # bounds wall-clock: on expiry the process (and, via its own process group,
21
+ # any children) is force-killed and whatever partial output was captured is
22
+ # returned with `timed_out: true`.
23
+ #
24
+ # Stdlib `Timeout.timeout` is deliberately NOT used (forbidden by the engine's
25
+ # fiber contract, see errors.rb); the deadline is enforced by
26
+ # `Thread#join(timeout)` — a single bounded blocking call — and the reader
27
+ # runs on its own thread so partial output survives the kill.
28
+ module Runner
29
+ # Grace period for the output reader to drain after the process is killed.
30
+ DRAIN_TIMEOUT = 2
31
+
32
+ module_function
33
+
34
+ # argv: the command as an argv array (never a shell string — no
35
+ # re-interpretation by the host shell).
36
+ # chdir: working directory of the spawned process.
37
+ # kill: optional extra teardown (e.g. `docker kill <name>`); the
38
+ # process group is ALWAYS killed regardless.
39
+ def run(argv, chdir:, timeout:, max_output:, env: {}, kill: nil)
40
+ # pgroup: true -> the child is a new group leader (pgid == pid), so a
41
+ # single kill on the negated pid reaps the command AND anything it forked.
42
+ Open3.popen2e(env, *argv, chdir: chdir, pgroup: true) do |stdin, out, wait_thr|
43
+ stdin.close
44
+ reader = Thread.new { out.read }
45
+
46
+ if wait_thr.join(timeout).nil?
47
+ terminate(wait_thr.pid, kill)
48
+ Result.new(exit_status: nil, output: drain(reader, max_output), timed_out: true)
49
+ else
50
+ Result.new(exit_status: wait_thr.value.exitstatus,
51
+ output: drain(reader, max_output), timed_out: false)
52
+ end
53
+ end
54
+ end
55
+
56
+ # Kill the whole process group, then run any provider-specific teardown.
57
+ # ESRCH (already gone) / EPERM are benign races — the process is dying.
58
+ def terminate(pid, kill)
59
+ Process.kill("KILL", -pid)
60
+ rescue Errno::ESRCH, Errno::EPERM
61
+ nil
62
+ ensure
63
+ kill&.call
64
+ end
65
+
66
+ # Collect the reader's output within a grace window. After a kill the write
67
+ # end closes and `out.read` returns promptly; the bound guards a reader that
68
+ # somehow does not (returns "" rather than blocking the fiber forever).
69
+ #
70
+ # The output of an arbitrary command is arbitrary bytes: scrubbed to valid
71
+ # UTF-8 before clipping (the clip is then by character, never splitting one)
72
+ # because it becomes a tool result and gets JSON-serialized downstream.
73
+ def drain(reader, max_output)
74
+ return "" if reader.join(DRAIN_TIMEOUT).nil?
75
+
76
+ Insika::Coercion.utf8(reader.value)[0, max_output]
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "coercion"
4
+ require_relative "sandbox/boundary"
5
+ require_relative "sandbox/runner"
6
+ require_relative "sandbox/local"
7
+ require_relative "sandbox/docker"
8
+
9
+ module Insika
10
+ # Sandbox primitive (item 35 / COMPETITIVE-ANALYSIS §4.6): a single, pluggable
11
+ # interface for confined execution, promoted to the core from the insika-code
12
+ # prototype. Two halves:
13
+ #
14
+ # * FS confinement (`Boundary`) — ALWAYS host-side, always on: every path a
15
+ # tool resolves is proven inside the root before any IO.
16
+ # * command exec — via a swappable PROVIDER: `local` (in-process, the default)
17
+ # or `docker` (isolated container). Providers are chosen by DATA, not code
18
+ # (config-over-code): the agent profile's `sandbox` block names the provider
19
+ # and its policy.
20
+ #
21
+ # `Env` is the object a tool holds; `Sandbox.build(config)` assembles one from
22
+ # the declarative config (the same hash shape stored on the profile).
23
+ module Sandbox
24
+ # Ergonomic alias so tools can rescue `Insika::Sandbox::Escape` without
25
+ # reaching into the Boundary. It IS Boundary::Escape (same class).
26
+ Escape = Boundary::Escape
27
+
28
+ DEFAULT_TIMEOUT = 120
29
+ DEFAULT_MAX_OUTPUT = 40_000
30
+
31
+ module_function
32
+
33
+ # config keys (all optional, string or symbol):
34
+ # provider "local" (default) | "docker"
35
+ # root confinement root (default: Dir.pwd)
36
+ # timeout per-exec wall-clock seconds (default 120)
37
+ # max_output bytes of combined output kept (default 40_000)
38
+ # + provider-specific keys (image/network/memory/cpus/... for docker)
39
+ def build(config = {})
40
+ cfg = Coercion.deep_stringify(config || {})
41
+ root = cfg["root"].to_s.empty? ? Dir.pwd : cfg["root"]
42
+ Env.new(
43
+ boundary: Boundary.new(root),
44
+ provider: provider_for(cfg),
45
+ timeout: Integer(cfg["timeout"] || DEFAULT_TIMEOUT),
46
+ max_output: Integer(cfg["max_output"] || DEFAULT_MAX_OUTPUT)
47
+ )
48
+ end
49
+
50
+ def provider_for(cfg)
51
+ case cfg["provider"].to_s
52
+ when "", "local" then Local.new(shell: cfg["shell"] || "/bin/bash")
53
+ when "docker" then Docker.new(cfg)
54
+ else raise ArgumentError, "unknown sandbox provider: #{cfg["provider"].inspect}"
55
+ end
56
+ end
57
+
58
+ # The per-agent sandbox environment: composes an FS boundary with an exec
59
+ # provider and the default limits. Delegates the boundary surface (resolve /
60
+ # relative / inside?) and adds `exec`. Immutable, safely shared across turns.
61
+ class Env
62
+ attr_reader :boundary, :provider, :root, :timeout, :max_output
63
+
64
+ def initialize(boundary:, provider:, timeout:, max_output:)
65
+ @boundary = boundary
66
+ @provider = provider
67
+ @root = boundary.root
68
+ @timeout = timeout
69
+ @max_output = max_output
70
+ end
71
+
72
+ # --- FS boundary (host-side, always on) ---
73
+ def resolve(path, for_write: false) = @boundary.resolve(path, for_write: for_write)
74
+ def inside?(abs) = @boundary.inside?(abs)
75
+ def relative(abs) = @boundary.relative(abs)
76
+
77
+ # --- command exec (via the provider) -> Result ---
78
+ def exec(command, timeout: @timeout, max_output: @max_output)
79
+ @provider.exec(command, root: @root, timeout: timeout, max_output: max_output)
80
+ end
81
+
82
+ def to_s = "Sandbox(#{@provider}, root=#{@root})"
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Checks a tool call's ARGUMENTS against the tool's JSON Schema, at call time.
5
+ # `violation` returns nil (fine) or ONE message describing what is wrong —
6
+ # the same idiom as EgressGuard, and consumed the same way: DataDefinedTool turns
7
+ # it into `{ error: … }` for the model, so a malformed call is a correctable
8
+ # answer instead of a request that goes out shaped wrong.
9
+ #
10
+ # Why this exists: the schema declares the contract, but nothing used to hold the
11
+ # model to it. A call carrying `["arroz"]` where the schema says
12
+ # `[{query, filters}]` was interpolated into the body as-is, the backend answered
13
+ # 200, and the wrong results came back with no error anywhere. Validating here
14
+ # closes that loop *and* names the fix in the message the model reads next.
15
+ #
16
+ # Scope: the safe subset ToolDefinition already validates
17
+ # (object/array/string/number/integer/boolean + enum + minItems/maxItems). Only what
18
+ # the schema DECLARES is checked; undeclared keys pass (providers add nothing, and
19
+ # `additionalProperties` is a tool author's business, not ours).
20
+ #
21
+ # NEVER coerces. The value the model sent is what reaches the request — the guard
22
+ # only decides whether the call may proceed, so turning it on cannot change the
23
+ # bytes of a call that was already correct.
24
+ module SchemaGuard
25
+ # A scalar the schema calls a number/integer/boolean may arrive as its string
26
+ # form ("2", "true") — providers do that, it is lossless, and rejecting it would
27
+ # break working tools for no gain. Structure (object/array) is NEVER lenient.
28
+ NUMERIC_RE = /\A-?\d+(?:\.\d+)?\z/
29
+ INTEGER_RE = /\A-?\d+\z/
30
+ BOOLEAN_STRINGS = %w[true false].freeze
31
+ MAX_REPORTED = 5
32
+
33
+ module_function
34
+
35
+ # schema: canonical JSON Schema (ToolDefinition#parameters). args: the model's
36
+ # kwargs (symbol keys). -> nil | String.
37
+ def violation(schema, args)
38
+ return nil unless schema.is_a?(Hash)
39
+
40
+ values = Insika::Coercion.deep_stringify(args || {})
41
+ missing = missing_top_level(schema, values)
42
+ return "missing required parameter(s): #{missing.join(', ')}" unless missing.empty?
43
+
44
+ problems = []
45
+ (schema["properties"] || {}).each do |pname, pschema|
46
+ value = values[pname.to_s]
47
+ next if value.nil?
48
+
49
+ problems.concat(check(value, pschema, pname.to_s))
50
+ break if problems.length >= MAX_REPORTED
51
+ end
52
+ return nil if problems.empty?
53
+
54
+ "invalid arguments: #{problems.first(MAX_REPORTED).join('; ')}"
55
+ end
56
+
57
+ # Top-level `required` uses PRESENCE (an empty string is missing), because these
58
+ # values feed `{{placeholder}}` interpolation — an empty one produces a silently
59
+ # broken URL/body. Nested `required` uses JSON Schema semantics (key present),
60
+ # where "" can be a legitimate value.
61
+ def missing_top_level(schema, values)
62
+ Array(schema["required"]).map(&:to_s).reject { |n| Insika::Coercion.present?(values[n]) }
63
+ end
64
+
65
+ # -> [String] problems found at/below `path`.
66
+ def check(value, schema, path)
67
+ return [] unless schema.is_a?(Hash)
68
+
69
+ case schema["type"].to_s
70
+ when "object" then check_object(value, schema, path)
71
+ when "array" then check_array(value, schema, path)
72
+ else check_scalar(value, schema, path)
73
+ end
74
+ end
75
+
76
+ def check_object(value, schema, path)
77
+ return ["#{path}: expected an object, got #{kind(value)}"] unless value.is_a?(Hash)
78
+
79
+ props = schema["properties"] || {}
80
+ missing = Array(schema["required"]).map(&:to_s).reject { |k| value.key?(k) }
81
+ problems = missing.map { |k| "#{path}.#{k}: missing (required)" }
82
+
83
+ props.each do |pname, pschema|
84
+ child = value[pname.to_s]
85
+ next if child.nil?
86
+
87
+ problems.concat(check(child, pschema, "#{path}.#{pname}"))
88
+ end
89
+ problems
90
+ end
91
+
92
+ def check_array(value, schema, path)
93
+ return ["#{path}: expected a list, got #{kind(value)}"] unless value.is_a?(Array)
94
+
95
+ problems = size_problems(value, schema, path)
96
+ problems + value.each_with_index.flat_map { |item, i| check(item, schema["items"], "#{path}[#{i}]") }
97
+ end
98
+
99
+ # minItems/maxItems are the only cardinality the authors actually write (a search
100
+ # that takes "1 or more pairs"), and an empty list is exactly the call that reads as
101
+ # success and returns nothing.
102
+ def size_problems(value, schema, path)
103
+ min = schema["minItems"]
104
+ max = schema["maxItems"]
105
+ problems = []
106
+ problems << "#{path}: needs at least #{min} item(s), got #{value.length}" if min.is_a?(Numeric) && value.length < min
107
+ problems << "#{path}: accepts at most #{max} item(s), got #{value.length}" if max.is_a?(Numeric) && value.length > max
108
+ problems
109
+ end
110
+
111
+ def check_scalar(value, schema, path)
112
+ type = schema["type"].to_s
113
+ return ["#{path}: expected #{type}, got #{kind(value)}"] unless scalar_ok?(value, type)
114
+
115
+ enum = schema["enum"]
116
+ return [] unless enum.is_a?(Array) && !enum.empty?
117
+ return [] if enum.map(&:to_s).include?(value.to_s)
118
+
119
+ ["#{path}: #{value.to_s.inspect} is not one of #{enum.map(&:to_s).join('/')}"]
120
+ end
121
+
122
+ def scalar_ok?(value, type)
123
+ return false if value.is_a?(Hash) || value.is_a?(Array)
124
+
125
+ case type
126
+ when "string" then true # any scalar stringifies losslessly
127
+ when "number" then value.is_a?(Numeric) || NUMERIC_RE.match?(value.to_s)
128
+ when "integer" then value.is_a?(Integer) || INTEGER_RE.match?(value.to_s)
129
+ when "boolean" then [true, false].include?(value) || BOOLEAN_STRINGS.include?(value.to_s)
130
+ else true # unknown type: not ours to police
131
+ end
132
+ end
133
+
134
+ # Name the shape the way a model reads it, not the way Ruby does.
135
+ def kind(value)
136
+ case value
137
+ when Hash then "an object"
138
+ when Array then "a list"
139
+ when String then "a string"
140
+ when Numeric then "a number"
141
+ when true, false then "a boolean"
142
+ when nil then "nothing"
143
+ else value.class.name.downcase
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Secret masking on the UI<->Store round-trip. OpenClaw
5
+ # convention: a secret NEVER comes back to the screen in plaintext — it becomes
6
+ # the sentinel `__OCULTO__`. On save, the sentinel coming back means "keep what
7
+ # was already there"; a new string replaces it; `""` clears it. Shared by
8
+ # llm_providers (api keys) and, in the future, MCP instances (credentials).
9
+ module SecretMasking
10
+ SENTINEL = "__OCULTO__"
11
+
12
+ module_function
13
+
14
+ # Value to DISPLAY: present -> sentinel (never the plaintext); absent -> nil.
15
+ def mask(value)
16
+ present?(value) ? SENTINEL : nil
17
+ end
18
+
19
+ # Value to PERSIST given what the form sent (`incoming`) and what already
20
+ # existed (`existing`):
21
+ # - not sent (nil) ............ preserves `existing`
22
+ # - sentinel `__OCULTO__` ..... preserves `existing`
23
+ # - "" (empty) ................ clears (nil)
24
+ # - new string ................ replaces
25
+ def reconcile(incoming, existing)
26
+ return existing if incoming.nil? || incoming == SENTINEL
27
+
28
+ s = incoming.to_s
29
+ s.empty? ? nil : s
30
+ end
31
+
32
+ def present?(value) = Insika::Coercion.present?(value)
33
+ end
34
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Server
5
+ module A2A
6
+ # A2A AgentCard: discovery for the exposed agent. One agent
7
+ # per deployment; HONEST capabilities (streaming/push false in this slice).
8
+ module AgentCard
9
+ def self.build(agent:, base_url:, skills: [], version: "0.1.0")
10
+ {
11
+ name: agent.id,
12
+ description: agent.base_prompt.to_s[0, 280],
13
+ url: "#{base_url}/a2a",
14
+ version: version,
15
+ protocolVersion: "0.2.5", # A2A wire — to confirm
16
+ capabilities: { streaming: false, pushNotifications: false, stateTransitionHistory: false },
17
+ defaultInputModes: ["text/plain"],
18
+ defaultOutputModes: ["text/plain"],
19
+ skills: Array(skills).map do |s|
20
+ { id: s.name, name: s.name, description: s.description, tags: [] }
21
+ end
22
+ }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "protocol"
5
+ require_relative "errors"
6
+ require_relative "message"
7
+ require_relative "task_projection"
8
+ require_relative "agent_card"
9
+
10
+ module Insika
11
+ module Server
12
+ module A2A
13
+ # A2A edge handler: injected sub-app mounted by Server::App. A2A is
14
+ # TRANSPORT — translates JSON-RPC↔Command on the SAME bus, projects
15
+ # Task→A2A, and NEVER leaks an exception (always an error object).
16
+ class App
17
+ def initialize(command_bus:, task_store:, session_store:, profiles:, skill_catalog:, config:)
18
+ @command_bus = command_bus
19
+ @task_store = task_store
20
+ @session_store = session_store
21
+ @profiles = Insika::ProfileSource.coerce(profiles)
22
+ @skill_catalog = skill_catalog
23
+ @config = config # { a2a_agent:, base_url: }
24
+ end
25
+
26
+ # POST /a2a — `body` is already the deserialized Hash. -> JSON-RPC envelope.
27
+ def rpc(body)
28
+ kind, parsed = Protocol.parse(body)
29
+ return Protocol.error(parsed[:id], parsed[:code], parsed[:message]) if kind == :error
30
+
31
+ dispatch_method(parsed[:id], parsed[:method], parsed[:params])
32
+ rescue StandardError => e # safety net: parse ok but something slipped through
33
+ code, message = Errors.from_exception(e)
34
+ Protocol.error(nil, code, message)
35
+ end
36
+
37
+ # GET /.well-known/agent-card.json
38
+ def agent_card
39
+ agent = @profiles[@config[:a2a_agent]]
40
+ skills = @skill_catalog.effective(agent.skills)
41
+ AgentCard.build(agent: agent, base_url: @config[:base_url], skills: skills)
42
+ end
43
+
44
+ private
45
+
46
+ def dispatch_method(id, method, params)
47
+ case method
48
+ when "message/send" then message_send(id, params)
49
+ when "tasks/get" then tasks_get(id, params)
50
+ when "tasks/cancel" then tasks_cancel(id, params)
51
+ else Protocol.error(id, Errors::METHOD_NOT_FOUND, "method '#{method}' not supported")
52
+ end
53
+ rescue StandardError => e # map core error -> A2A code
54
+ code, message = Errors.from_exception(e)
55
+ Protocol.error(id, code, message)
56
+ end
57
+
58
+ # message/send returns the Task; missing contextId -> creates a session (the
59
+ # server assigns the contextId; ensures a transcript for tasks/get).
60
+ def message_send(id, params)
61
+ message = params["message"] || {}
62
+ session_id = message["contextId"] || params["contextId"]
63
+ session_id ||= @command_bus.dispatch(build(:create_session, {})).id
64
+ text = Message.text_from(message)
65
+ result = @command_bus.dispatch(build(:send_message,
66
+ agent: @config[:a2a_agent], message: text, session_id: session_id))
67
+ task = @task_store.find(result[:task_id])
68
+ Protocol.result(id, TaskProjection.call(task, at: now))
69
+ end
70
+
71
+ def tasks_get(id, params)
72
+ task = @task_store.find(params["id"])
73
+ return Protocol.error(id, Errors::TASK_NOT_FOUND, "task not found: #{params['id']}") if task.nil?
74
+
75
+ Protocol.result(id, TaskProjection.call(task, at: now,
76
+ content: terminal_content(task),
77
+ error: terminal_error(task)))
78
+ end
79
+
80
+ def tasks_cancel(id, params)
81
+ task_id = params["id"]
82
+ return Protocol.error(id, Errors::TASK_NOT_FOUND, "task not found: #{task_id}") if @task_store.find(task_id).nil?
83
+
84
+ @command_bus.dispatch(build(:cancel_task, task_id: task_id))
85
+ Protocol.result(id, TaskProjection.call(@task_store.find(task_id), at: now))
86
+ end
87
+
88
+ # Final content = last `assistant` message in the session transcript.
89
+ def terminal_content(task)
90
+ return nil unless task.session_id
91
+
92
+ session = @session_store.find(task.session_id)
93
+ return nil unless session
94
+
95
+ msg = session.messages.reverse.find { |m| (m["role"] || m[:role]).to_s == "assistant" }
96
+ msg && (msg["content"] || msg[:content])
97
+ end
98
+
99
+ # Error message from the last :failed Execution.
100
+ def terminal_error(task)
101
+ exec = task.executions.last
102
+ return nil unless exec && exec.outcome.to_s == "failed"
103
+
104
+ exec.error && (exec.error["message"] || exec.error[:message])
105
+ end
106
+
107
+ def build(type, payload) = Insika::Command.build(type, payload, transport: :a2a)
108
+ def now = Time.now.utc.iso8601
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "message"
4
+
5
+ module Insika
6
+ module Server
7
+ module A2A
8
+ # JSON-RPC response envelope carrying `error` (the remote refused/failed).
9
+ class RemoteError < StandardError
10
+ attr_reader :code
11
+
12
+ def initialize(code, message)
13
+ @code = code
14
+ super(message)
15
+ end
16
+ end
17
+
18
+ # Outbound A2A client: calls a remote A2A agent. PURE — the
19
+ # `http` (post_json(url, body) -> Hash) is injected; the smoke test uses loopback.
20
+ class Client
21
+ TERMINAL = %w[completed failed canceled rejected input-required].freeze
22
+ POLL_DELAY = 0.02
23
+
24
+ def initialize(http:, poll_max: 30, sleeper: nil)
25
+ @http = http
26
+ @poll_max = poll_max
27
+ @sleeper = sleeper || ->(seconds) { Async::Task.current&.sleep(seconds) }
28
+ @id_seq = 0
29
+ end
30
+
31
+ # -> remote A2A Task (Hash) | raise RemoteError.
32
+ def send_message(url, text, context_id: nil)
33
+ message = { "role" => "user", "parts" => [{ "kind" => "text", "text" => text.to_s }] }
34
+ message["contextId"] = context_id if context_id
35
+ parse_envelope(@http.post_json(url, request("message/send", { "message" => message })))
36
+ end
37
+
38
+ # -> remote A2A Task (Hash) | raise RemoteError.
39
+ def get_task(url, task_id)
40
+ parse_envelope(@http.post_json(url, request("tasks/get", { "id" => task_id })))
41
+ end
42
+
43
+ # High level: send + poll until terminal. ALWAYS does ≥1 get_task — the
44
+ # inbound message/send projects the Task WITHOUT `status.message` (the content
45
+ # only comes from tasks/get). -> { text:, state:, id: } | { error:, state:, id: }.
46
+ # NEVER raises (wraps RemoteError).
47
+ def call(url, text, context_id: nil)
48
+ sent = send_message(url, text, context_id: context_id)
49
+ id = remote_id(sent)
50
+ task = get_task(url, id)
51
+ attempts = 0
52
+ until TERMINAL.include?(remote_state(task)) || attempts >= @poll_max
53
+ @sleeper.call(POLL_DELAY)
54
+ attempts += 1
55
+ task = get_task(url, id)
56
+ end
57
+ project(task, id)
58
+ rescue RemoteError => e
59
+ { error: e.message, state: "failed", id: nil }
60
+ end
61
+
62
+ private
63
+
64
+ def request(method, params)
65
+ @id_seq += 1
66
+ { "jsonrpc" => "2.0", "id" => @id_seq, "method" => method, "params" => params }
67
+ end
68
+
69
+ def parse_envelope(response)
70
+ raise RemoteError.new(nil, "invalid A2A response") unless response.is_a?(Hash)
71
+ return response["result"] if response.key?("result")
72
+
73
+ err = response["error"]
74
+ raise RemoteError.new(err && err["code"], (err && err["message"]).to_s) if err
75
+
76
+ raise RemoteError.new(nil, "A2A response has neither result nor error")
77
+ end
78
+
79
+ def remote_state(task) = task.is_a?(Hash) ? task.dig("status", "state").to_s : ""
80
+ def remote_text(task) = Message.text_from(task.is_a?(Hash) ? task.dig("status", "message") : nil)
81
+ def remote_id(task) = task.is_a?(Hash) ? task["id"] : nil
82
+
83
+ # completed/input-required -> text; failed/canceled/rejected -> error
84
+ # (text if present, else the state itself — avoids the "".truthy footgun);
85
+ # exhausted poll (non-terminal) -> error.
86
+ def project(task, id)
87
+ state = remote_state(task)
88
+ text = remote_text(task)
89
+ case state
90
+ when "completed", "input-required"
91
+ { text: text, state: state, id: id }
92
+ when "failed", "canceled", "rejected"
93
+ { error: text.empty? ? state : text, state: state, id: id }
94
+ else
95
+ { error: "remote task did not complete (state: #{state})", state: state, id: id }
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Server
5
+ module A2A
6
+ # A2A adapter error map: standard JSON-RPC 2.0 codes +
7
+ # A2A extensions. The `App` NEVER leaks an exception — always an error object.
8
+ module Errors
9
+ PARSE_ERROR = -32_700
10
+ INVALID_REQUEST = -32_600
11
+ METHOD_NOT_FOUND = -32_601
12
+ INVALID_PARAMS = -32_602
13
+ INTERNAL_ERROR = -32_603
14
+ TASK_NOT_FOUND = -32_001 # A2A TaskNotFoundError
15
+ TASK_NOT_CANCELABLE = -32_002 # A2A TaskNotCancelableError
16
+
17
+ # Core exception -> [code, message]. A task `NotFoundError` is handled
18
+ # EXPLICITLY in the App (tasks/get returns TASK_NOT_FOUND directly), so
19
+ # here `NotFoundError` falls into INVALID_PARAMS (missing agent/session in the
20
+ # request). Anything else -> INTERNAL_ERROR (generic message, no stack).
21
+ def self.from_exception(error)
22
+ case error
23
+ when Insika::ValidationError, Insika::NotFoundError
24
+ [INVALID_PARAMS, error.message]
25
+ else
26
+ [INTERNAL_ERROR, "internal error"]
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end