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,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
|