forcedream 0.3.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 +7 -0
- data/lib/force_dream/a2a.rb +48 -0
- data/lib/force_dream/agents.rb +60 -0
- data/lib/force_dream/canonical.rb +63 -0
- data/lib/force_dream/http.rb +70 -0
- data/lib/force_dream/invoke.rb +85 -0
- data/lib/force_dream/verify.rb +166 -0
- data/lib/force_dream.rb +101 -0
- metadata +47 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0fb84ab881322d038a80eb8d47d9a83707df083f5cb534cdf7e053da1086cdf4
|
|
4
|
+
data.tar.gz: 6468e6e2bba345b0958fb2438182816f87e7dbf9e70537c36f0b9ab1193e77af
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4ee9f5d78d12b9c9990c0fd45f4071a5390ef4031d9e72205c62c97294623e187463a6ffc66a0b2d91c3a9c651ca7a6aa2ef8467ef982755d65e58c0a00919ba
|
|
7
|
+
data.tar.gz: 195654365c609adf8a5cd3289036346fb3cef46eef12f4f1f1255d1435227fe466940845e3a8058b922dee29a235176e6ccf84d52cea162643b2fdb84326e6fe
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForceDream
|
|
4
|
+
# Real A2A (agent-to-agent) bindings -- lets a developer register their own agent on the
|
|
5
|
+
# real A2A network (making it discoverable and invokable by others, earning them revenue
|
|
6
|
+
# when invoked) and invoke other registered agents. Endpoint shapes confirmed directly
|
|
7
|
+
# against the real backend source (api/server.ts) earlier tonight, ported here from that
|
|
8
|
+
# same verified source -- not re-guessed for Ruby.
|
|
9
|
+
#
|
|
10
|
+
# Uses a real, different credential from FD_LIVE_KEY/invoke(): these four endpoints all
|
|
11
|
+
# authenticate via the backend's resolveUserId(), which requires an sk_fd_... account key
|
|
12
|
+
# specifically -- confirmed directly, not assumed (the same class of key-type mismatch
|
|
13
|
+
# already caught and fixed once elsewhere tonight). Passing an fd_live_ key here will fail
|
|
14
|
+
# auth.
|
|
15
|
+
module A2A
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def register_agent(api_base:, account_key:, agent_slug:, capabilities:, price_per_call_pence: nil,
|
|
19
|
+
name: nil, description: nil, version: nil, recommends: nil)
|
|
20
|
+
body = { agent_slug: agent_slug, capabilities: capabilities }
|
|
21
|
+
body[:price_per_call_pence] = price_per_call_pence if price_per_call_pence
|
|
22
|
+
body[:name] = name if name
|
|
23
|
+
body[:description] = description if description
|
|
24
|
+
body[:version] = version if version
|
|
25
|
+
body[:recommends] = recommends if recommends
|
|
26
|
+
|
|
27
|
+
Http.post("#{api_base}/v1/a2a/register-agent", body: body, bearer: account_key)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def delete_agent(api_base:, account_key:, agent_slug:)
|
|
31
|
+
Http.post("#{api_base}/v1/a2a/delete-agent", body: { agent_slug: agent_slug }, bearer: account_key)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def invoke(api_base:, account_key:, target_agent:, payload:, task_type: 'general',
|
|
35
|
+
amount_pence: nil, idempotency_key: nil, fx_quote_id: nil)
|
|
36
|
+
body = { target_agent: target_agent, payload: payload, task_type: task_type }
|
|
37
|
+
body[:amount_pence] = amount_pence if amount_pence
|
|
38
|
+
body[:idempotency_key] = idempotency_key if idempotency_key
|
|
39
|
+
body[:fx_quote_id] = fx_quote_id if fx_quote_id
|
|
40
|
+
|
|
41
|
+
Http.post("#{api_base}/v1/a2a/invoke", body: body, bearer: account_key)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def poll_result(api_base:, account_key:, invoke_id:)
|
|
45
|
+
Http.get("#{api_base}/v1/a2a/result/#{invoke_id}", bearer: account_key)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForceDream
|
|
4
|
+
# Ported precisely from @forcedream/mcp-server's search_agents.ts (via the same logic
|
|
5
|
+
# already proven in every other SDK tonight). Real, load-bearing fact confirmed directly
|
|
6
|
+
# from that source in earlier work tonight, not assumed here: the server has no working
|
|
7
|
+
# server-side capability/query filter on /v1/agents/list -- filtering must happen
|
|
8
|
+
# client-side, after fetching the full list. Also merges in real reliability data from the
|
|
9
|
+
# separate /v1/agents/reliability endpoint, exactly as every other SDK does.
|
|
10
|
+
module Agents
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def search_agents_filtered(api_base:, capability: nil, query: nil)
|
|
14
|
+
data = Http.get("#{api_base}/v1/agents/list")
|
|
15
|
+
agents = data['agents'].is_a?(Array) ? data['agents'] : []
|
|
16
|
+
|
|
17
|
+
rel_data = begin
|
|
18
|
+
Http.get("#{api_base}/v1/agents/reliability")
|
|
19
|
+
rescue StandardError
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
reliability_by_slug = {}
|
|
24
|
+
if rel_data && rel_data['agents'].is_a?(Array)
|
|
25
|
+
rel_data['agents'].each do |ra|
|
|
26
|
+
reliability_by_slug[ra['agent_slug']] = ra['reliability'] if ra['agent_slug'] && ra['reliability']
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if capability
|
|
31
|
+
cap_lower = capability.downcase
|
|
32
|
+
agents = agents.select do |a|
|
|
33
|
+
(a['capabilities'] || []).any? { |c| c.to_s.downcase == cap_lower }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
if query
|
|
37
|
+
q_lower = query.downcase
|
|
38
|
+
agents = agents.select do |a|
|
|
39
|
+
slug = (a['slug'] || '').downcase
|
|
40
|
+
name = (a['name'] || '').downcase
|
|
41
|
+
next true if slug.include?(q_lower) || name.include?(q_lower)
|
|
42
|
+
|
|
43
|
+
(a['capabilities'] || []).any? { |c| c.to_s.downcase.include?(q_lower) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
enriched = agents.map do |a|
|
|
48
|
+
a.merge('health' => reliability_by_slug[a['slug']])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
'count' => enriched.length,
|
|
53
|
+
'agents' => enriched,
|
|
54
|
+
'note' => enriched.empty? ? \
|
|
55
|
+
'No agents matched. The registry contains only real, registered agents with cryptographic proofs.' : \
|
|
56
|
+
'Metrics are system-derived from proofs/ledger (proof_count, success_rate) -- never self-reported. Health (success_rate, avg_latency_ms, sample_size) is honestly null where no real reliability data exists yet.'
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module ForceDream
|
|
7
|
+
# Exact replica of the server's wfCanonical: JSON.stringify(obj, Object.keys(obj).sort()).
|
|
8
|
+
# Sorted keys, no whitespace. Ported from the same logic already proven in eight other
|
|
9
|
+
# language SDKs tonight (JS, Python, Go, Rust, Java, C#, PHP, Kotlin) -- not invented fresh
|
|
10
|
+
# for Ruby.
|
|
11
|
+
module Canonical
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Uses a custom, minimal serializer rather than Ruby's own #to_json, since exact
|
|
15
|
+
# byte-for-byte output matters here (a single differing byte changes the signed bytes
|
|
16
|
+
# and breaks every signature check). Confirmed directly (not assumed) before writing
|
|
17
|
+
# this: Ruby's #to_json always includes a decimal point for Float values, even whole
|
|
18
|
+
# ones (1783860125.0, not 1783860125) -- a real, different-shaped version of the same
|
|
19
|
+
# class of bug every other language SDK tonight had to fix in its own way.
|
|
20
|
+
def wf_canonical(obj)
|
|
21
|
+
sorted_keys = obj.keys.sort
|
|
22
|
+
parts = sorted_keys.map do |k|
|
|
23
|
+
%("#{escape(k)}":#{serialize(obj[k])})
|
|
24
|
+
end
|
|
25
|
+
"{#{parts.join(',')}}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def serialize(value)
|
|
29
|
+
case value
|
|
30
|
+
when nil then 'null'
|
|
31
|
+
when String then %("#{escape(value)}")
|
|
32
|
+
when Numeric then js_number(value.to_f)
|
|
33
|
+
when true, false then value.to_s
|
|
34
|
+
else
|
|
35
|
+
raise ArgumentError, "Unsupported type for canonicalization: #{value.class}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def escape(str)
|
|
40
|
+
str.gsub('\\', '\\\\\\\\').gsub('"', '\\"').gsub("\n", '\\n').gsub("\r", '\\r').gsub("\t", '\\t')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Mirrors JS's Number(x) -> JSON.stringify() behavior: whole values with no decimal
|
|
44
|
+
# point, fractional values preserved, never scientific notation. Confirmed directly
|
|
45
|
+
# (see module comment above) that Ruby's default #to_json needed this same correction,
|
|
46
|
+
# matching the defensive posture used even for languages where the default behavior
|
|
47
|
+
# turned out to already be safe on the cases tested.
|
|
48
|
+
def js_number(d)
|
|
49
|
+
if d.finite? && d == d.to_i && d.abs < 1e15
|
|
50
|
+
d.to_i.to_s
|
|
51
|
+
else
|
|
52
|
+
# Ruby's Float#to_s is confirmed (via direct test) to avoid scientific notation and
|
|
53
|
+
# match JS's shortest-round-trip representation for the real fractional values this
|
|
54
|
+
# SDK actually deals with (pence amounts, sub-second timestamp fractions).
|
|
55
|
+
d.to_s
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def sha256_hex(str)
|
|
60
|
+
Digest::SHA256.hexdigest(str)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module ForceDream
|
|
8
|
+
# Thin wrapper over Ruby's standard-library Net::HTTP -- no external HTTP gem needed.
|
|
9
|
+
module Http
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
Result = Struct.new(:status, :json, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
def get(url, bearer: nil)
|
|
15
|
+
uri = URI(url)
|
|
16
|
+
req = Net::HTTP::Get.new(uri)
|
|
17
|
+
req['Authorization'] = "Bearer #{bearer}" if bearer
|
|
18
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') { |http| http.request(req) }
|
|
19
|
+
parse(response)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def post(url, body:, bearer: nil)
|
|
23
|
+
uri = URI(url)
|
|
24
|
+
req = Net::HTTP::Post.new(uri)
|
|
25
|
+
req['Content-Type'] = 'application/json'
|
|
26
|
+
req['Authorization'] = "Bearer #{bearer}" if bearer
|
|
27
|
+
req.body = body.to_json
|
|
28
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') { |http| http.request(req) }
|
|
29
|
+
parse(response)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns the parsed JSON body directly (raising on a non-2xx status), matching the
|
|
33
|
+
# simplest, throwing style already used by the Kotlin/Java SDKs' main methods -- callers
|
|
34
|
+
# that need the real status without raising (delete-agent's real 404/403/200 are all
|
|
35
|
+
# meaningful) use get_result/post_result below instead.
|
|
36
|
+
def parse(response)
|
|
37
|
+
json = safe_parse(response.body)
|
|
38
|
+
status = response.code.to_i
|
|
39
|
+
raise "HTTP #{status}: #{response.body}" unless (200..299).cover?(status)
|
|
40
|
+
|
|
41
|
+
json
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def get_result(url, bearer: nil)
|
|
45
|
+
uri = URI(url)
|
|
46
|
+
req = Net::HTTP::Get.new(uri)
|
|
47
|
+
req['Authorization'] = "Bearer #{bearer}" if bearer
|
|
48
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') { |http| http.request(req) }
|
|
49
|
+
Result.new(status: response.code.to_i, json: safe_parse(response.body))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def post_result(url, body:, bearer: nil)
|
|
53
|
+
uri = URI(url)
|
|
54
|
+
req = Net::HTTP::Post.new(uri)
|
|
55
|
+
req['Content-Type'] = 'application/json'
|
|
56
|
+
req['Authorization'] = "Bearer #{bearer}" if bearer
|
|
57
|
+
req.body = body.to_json
|
|
58
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') { |http| http.request(req) }
|
|
59
|
+
Result.new(status: response.code.to_i, json: safe_parse(response.body))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def safe_parse(body)
|
|
63
|
+
return {} if body.nil? || body.empty?
|
|
64
|
+
|
|
65
|
+
JSON.parse(body)
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
{}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module ForceDream
|
|
6
|
+
InvokeResult = Struct.new(
|
|
7
|
+
:status, :agent, :task_id, :output, :charged_pence, :proof_id, :message, :error,
|
|
8
|
+
keyword_init: true
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
# Ported precisely from @forcedream/mcp-server's invoke_agent.ts (via the same logic
|
|
12
|
+
# already proven in every other SDK tonight) -- exact endpoints, exact polling interval
|
|
13
|
+
# ramp (starts 2500ms, +1000ms per attempt, capped at 6000ms), exact status handling.
|
|
14
|
+
# Invokes ONCE; never re-invokes on timeout (would double-charge) -- returns a pollable
|
|
15
|
+
# task_id instead.
|
|
16
|
+
module Invoke
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def invoke_agent_polling(api_base:, api_key:, agent_slug:, task:, max_wait_seconds: 60)
|
|
20
|
+
max_wait_ms = [5, [120, max_wait_seconds].min].max * 1000
|
|
21
|
+
encoded_slug = CGI.escape(agent_slug)
|
|
22
|
+
|
|
23
|
+
inv = Http.post_result("#{api_base}/v1/agents/#{encoded_slug}/invoke", body: { task: task }, bearer: api_key)
|
|
24
|
+
|
|
25
|
+
return InvokeResult.new(status: 'error', agent: agent_slug, message: 'Invalid API key (401).', error: 'invalid_key') if inv.status == 401
|
|
26
|
+
|
|
27
|
+
task_id = inv.json['task_id']
|
|
28
|
+
unless task_id
|
|
29
|
+
err_msg = inv.json['error'] || inv.json['note'] || 'no task_id'
|
|
30
|
+
return InvokeResult.new(status: 'error', agent: agent_slug, message: "Invoke failed (HTTP #{inv.status}): #{err_msg}", error: 'invoke_failed')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
encoded_task_id = CGI.escape(task_id)
|
|
34
|
+
start_ms = (Time.now.to_f * 1000).to_i
|
|
35
|
+
interval_ms = 2500
|
|
36
|
+
|
|
37
|
+
while ((Time.now.to_f * 1000).to_i - start_ms) < max_wait_ms
|
|
38
|
+
sleep(interval_ms / 1000.0)
|
|
39
|
+
|
|
40
|
+
poll = Http.get_result("#{api_base}/v1/agents/#{encoded_slug}/result/#{encoded_task_id}", bearer: api_key)
|
|
41
|
+
d = poll.json
|
|
42
|
+
poll_status = d['status'] || d['outcome'] || ''
|
|
43
|
+
ok_true = d['ok'] == true
|
|
44
|
+
|
|
45
|
+
if %w[completed succeeded].include?(poll_status) || ok_true
|
|
46
|
+
output = d['output']
|
|
47
|
+
insufficient = d['outcome'] == 'insufficient' || (output.is_a?(Hash) && output['confidence'] == 'insufficient')
|
|
48
|
+
|
|
49
|
+
if insufficient
|
|
50
|
+
return InvokeResult.new(status: 'insufficient', agent: agent_slug, task_id: task_id, output: output, charged_pence: 0,
|
|
51
|
+
message: 'Agent returned insufficient evidence and declined rather than fabricate. Charged nothing.')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
charged = d['charged_pence']
|
|
55
|
+
proof_id = d['proof_id'] || task_id
|
|
56
|
+
return InvokeResult.new(status: 'completed', agent: agent_slug, task_id: task_id, output: output, charged_pence: charged,
|
|
57
|
+
proof_id: proof_id, message: "Completed. Charged #{charged || 0}p. Cryptographically proven (proof_id #{proof_id}).")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if poll_status == 'insufficient'
|
|
61
|
+
return InvokeResult.new(status: 'insufficient', agent: agent_slug, task_id: task_id, output: d['output'], charged_pence: 0,
|
|
62
|
+
message: 'Agent declined (insufficient evidence). Charged nothing.')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if poll_status == 'charge_failed'
|
|
66
|
+
reason = d['reason'] || 'insufficient_balance'
|
|
67
|
+
return InvokeResult.new(status: 'error', agent: agent_slug, task_id: task_id, charged_pence: 0, error: 'charge_failed',
|
|
68
|
+
message: "Charge failed: #{reason}. Nothing charged or delivered. Top up and retry.")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if %w[failed dead_letter].include?(poll_status)
|
|
72
|
+
reason = d['reason'] || d['last_error'] || 'unknown'
|
|
73
|
+
return InvokeResult.new(status: 'error', agent: agent_slug, task_id: task_id, message: "Task #{poll_status}: #{reason}", error: poll_status)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
interval_ms = [interval_ms + 1000, 6000].min
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
InvokeResult.new(status: 'pending', agent: agent_slug, task_id: task_id,
|
|
80
|
+
message: "Still processing after #{max_wait_ms / 1000}s. Not re-invoked (would double-charge). Poll the result later with this task_id.")
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
InvokeResult.new(status: 'error', agent: agent_slug, message: "Invoke request failed: #{e.message}", error: 'request_failed')
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'base64'
|
|
5
|
+
require 'cgi'
|
|
6
|
+
|
|
7
|
+
module ForceDream
|
|
8
|
+
VerifyResult = Struct.new(
|
|
9
|
+
:verified, :task_id, :key_id, :algorithm, :fields_signed, :trustless, :message,
|
|
10
|
+
keyword_init: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# Trustlessly verifies a ForceDream proof's Ed25519 signature entirely client-side.
|
|
14
|
+
# ForceDream is never asked whether the proof is valid -- the math decides, locally.
|
|
15
|
+
#
|
|
16
|
+
# Uses Ruby's standard-library OpenSSL::PKey for Ed25519 -- no external gem needed.
|
|
17
|
+
# Confirmed directly, live, before writing any client logic here: OpenSSL::PKey.read
|
|
18
|
+
# parses the real SPKI PEM the API returns directly (no manual byte-offset extraction
|
|
19
|
+
# needed, unlike PHP's sodium or Swift's CryptoKit, both of which need raw key bytes
|
|
20
|
+
# only); a real generate/sign/verify/tamper-detection round-trip was run and confirmed
|
|
21
|
+
# correct before relying on this.
|
|
22
|
+
module Verify
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def build_signable(proof)
|
|
26
|
+
has_ext = !proof['external_cost_hash'].nil?
|
|
27
|
+
|
|
28
|
+
base = {
|
|
29
|
+
'task_id' => text_or_nil(proof['task_id']),
|
|
30
|
+
'agent_id' => text_or_nil(proof['agent_id']),
|
|
31
|
+
'input_hash' => text_or_nil(proof['input_hash']),
|
|
32
|
+
'output_hash' => text_or_nil(proof['output_hash']),
|
|
33
|
+
'cost_pence' => number_or_zero(proof['cost_pence']),
|
|
34
|
+
'budget_pence' => number_or_zero(proof['budget_pence']),
|
|
35
|
+
'started_at' => number_or_zero(proof['started_at']),
|
|
36
|
+
'completed_at' => string_value(proof['completed_at'])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if has_ext
|
|
40
|
+
base['external_cost_hash'] = string_value(proof['external_cost_hash'])
|
|
41
|
+
base['retrieved_count'] = number_or_zero(proof['retrieved_count'] || 0)
|
|
42
|
+
# Model binding: the server records which provider and model actually served
|
|
43
|
+
# the execution and binds them into the signed payload. Conditional, so a proof
|
|
44
|
+
# issued before this existed canonicalises exactly as it did then -- adding them
|
|
45
|
+
# unconditionally would break every proof already in the wild.
|
|
46
|
+
n = 10
|
|
47
|
+
unless proof['inference_provider'].nil?
|
|
48
|
+
base['inference_provider'] = string_value(proof['inference_provider'])
|
|
49
|
+
n += 1
|
|
50
|
+
end
|
|
51
|
+
unless proof['inference_model'].nil?
|
|
52
|
+
base['inference_model'] = string_value(proof['inference_model'])
|
|
53
|
+
n += 1
|
|
54
|
+
end
|
|
55
|
+
[base, n]
|
|
56
|
+
else
|
|
57
|
+
[base, 8]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def text_or_nil(v)
|
|
62
|
+
v.is_a?(String) ? v : nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def number_or_zero(v)
|
|
66
|
+
case v
|
|
67
|
+
when Numeric then v.to_f
|
|
68
|
+
when String then v.to_f
|
|
69
|
+
else 0.0
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def string_value(v)
|
|
74
|
+
return v if v.is_a?(String)
|
|
75
|
+
return Canonical.js_number(v.to_f) if v.is_a?(Numeric)
|
|
76
|
+
|
|
77
|
+
''
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Exact replica of the server's verifyMerkleInclusion. Each sibling carries its
|
|
81
|
+
# own position, so ordering is never derived from leaf_index. Hashing is over
|
|
82
|
+
# concatenated HEX STRINGS, not raw bytes -- matching the server exactly. Empty
|
|
83
|
+
# siblings means the root is the leaf digest unchanged (the batch_size == 1 case,
|
|
84
|
+
# which is every real proof the platform has emitted to date).
|
|
85
|
+
def verify_merkle_inclusion(leaf_hash, siblings, expected_root)
|
|
86
|
+
current = leaf_hash
|
|
87
|
+
siblings.each do |step|
|
|
88
|
+
sibling_hash = step['hash']
|
|
89
|
+
return false unless sibling_hash.is_a?(String)
|
|
90
|
+
|
|
91
|
+
current = if step['position'] == 'right'
|
|
92
|
+
Canonical.sha256_hex(current + sibling_hash)
|
|
93
|
+
else
|
|
94
|
+
Canonical.sha256_hex(sibling_hash + current)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
current == expected_root
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def verify_proof(api_base:, task_id: nil, proof: nil)
|
|
101
|
+
if proof.nil?
|
|
102
|
+
raise ArgumentError, 'Provide task_id or proof' if task_id.nil?
|
|
103
|
+
|
|
104
|
+
data = Http.get("#{api_base}/v1/workforce/proof/#{CGI.escape(task_id)}/public")
|
|
105
|
+
raise 'proof_not_found' unless data['proof']
|
|
106
|
+
|
|
107
|
+
proof = data['proof']
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
key_data = Http.get("#{api_base}/v1/workforce/proof/public-key")
|
|
111
|
+
key_id = key_data['key_id']
|
|
112
|
+
pem = key_data['public_key_pem'] || ''
|
|
113
|
+
|
|
114
|
+
verifying_key = begin
|
|
115
|
+
OpenSSL::PKey.read(pem)
|
|
116
|
+
rescue StandardError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
signable, field_count = build_signable(proof)
|
|
121
|
+
digest_hex = Canonical.sha256_hex(Canonical.wf_canonical(signable))
|
|
122
|
+
|
|
123
|
+
algorithm = proof['algorithm']
|
|
124
|
+
verified = false
|
|
125
|
+
|
|
126
|
+
if verifying_key && proof['signature'] &&
|
|
127
|
+
(algorithm.nil? || algorithm == 'Ed25519' || algorithm == 'Ed25519-batched')
|
|
128
|
+
begin
|
|
129
|
+
sig_bytes = Base64.decode64(proof['signature'])
|
|
130
|
+
|
|
131
|
+
if algorithm == 'Ed25519-batched'
|
|
132
|
+
# A batched proof is only as strong as this real double-check: the digest
|
|
133
|
+
# must genuinely be a leaf of the claimed root, verified BEFORE the
|
|
134
|
+
# signature is trusted. The signature is over the ROOT, not the digest.
|
|
135
|
+
root = proof['merkle_root']
|
|
136
|
+
inclusion = proof['inclusion_proof']
|
|
137
|
+
siblings = inclusion.is_a?(Hash) ? inclusion['siblings'] : nil
|
|
138
|
+
|
|
139
|
+
if root.is_a?(String) && !root.empty? && siblings.is_a?(Array) &&
|
|
140
|
+
verify_merkle_inclusion(digest_hex, siblings, root)
|
|
141
|
+
root_bytes = [root].pack('H*')
|
|
142
|
+
verified = verifying_key.verify(nil, sig_bytes, root_bytes)
|
|
143
|
+
end
|
|
144
|
+
else
|
|
145
|
+
digest_bytes = [digest_hex].pack('H*')
|
|
146
|
+
verified = verifying_key.verify(nil, sig_bytes, digest_bytes)
|
|
147
|
+
end
|
|
148
|
+
rescue StandardError
|
|
149
|
+
verified = false
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
VerifyResult.new(
|
|
154
|
+
verified: verified,
|
|
155
|
+
task_id: proof['task_id'],
|
|
156
|
+
key_id: key_id,
|
|
157
|
+
algorithm: algorithm || 'Ed25519',
|
|
158
|
+
fields_signed: field_count,
|
|
159
|
+
trustless: true,
|
|
160
|
+
message: verified ? \
|
|
161
|
+
'Signature mathematically verified. This proof was signed by ForceDream and has not been altered.' : \
|
|
162
|
+
'Signature verification FAILED. The proof was altered or not signed by ForceDream.'
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/force_dream.rb
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'force_dream/canonical'
|
|
4
|
+
require_relative 'force_dream/http'
|
|
5
|
+
require_relative 'force_dream/verify'
|
|
6
|
+
require_relative 'force_dream/agents'
|
|
7
|
+
require_relative 'force_dream/invoke'
|
|
8
|
+
require_relative 'force_dream/a2a'
|
|
9
|
+
|
|
10
|
+
module ForceDream
|
|
11
|
+
# A real, honestly-scoped client for the ForceDream API. Wraps only endpoints verified
|
|
12
|
+
# working directly against the live, production API -- not the full platform surface.
|
|
13
|
+
#
|
|
14
|
+
# Two genuinely different credentials, deliberately kept separate rather than conflated --
|
|
15
|
+
# the same design already used in the Kotlin/Swift SDKs tonight, itself a direct
|
|
16
|
+
# correction of an earlier mistake: api_key is the real fd_live_... billing key (invoke,
|
|
17
|
+
# get_balance -- spends a prepaid balance). account_key is the real sk_fd_... account key
|
|
18
|
+
# (register_agent, a2a_invoke/a2a_poll_result/delete_agent -- confirmed directly against
|
|
19
|
+
# the real backend's resolveUserId(), which requires this specific format).
|
|
20
|
+
class Client
|
|
21
|
+
def initialize(api_key: nil, account_key: nil, api_base: 'https://api.forcedream.ai')
|
|
22
|
+
@api_key = api_key
|
|
23
|
+
@account_key = account_key
|
|
24
|
+
@api_base = api_base
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Create a new ForceDream account. No API key needed -- this is how you get one.
|
|
28
|
+
# Returns a real fd_live_ billing key (and a real sk_fd_ account key) with a small,
|
|
29
|
+
# real trial balance already seeded.
|
|
30
|
+
def self.signup(email:, marketing_consent: false, api_base: 'https://api.forcedream.ai')
|
|
31
|
+
Http.post("#{api_base}/api/signup", body: { email: email, marketing_consent: marketing_consent })
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Real, current account balance. Requires the fd_live_ api_key.
|
|
35
|
+
def get_balance
|
|
36
|
+
raise 'get_balance requires an api_key' unless @api_key
|
|
37
|
+
|
|
38
|
+
Http.get("#{@api_base}/v1/account/balance", bearer: @api_key)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Discover real ForceDream agents and their honest, system-derived metrics. No key
|
|
42
|
+
# needed -- every field here is computed from real proofs and ledger entries, never
|
|
43
|
+
# self-reported. Filtering happens client-side (the server has no working server-side
|
|
44
|
+
# filter for this).
|
|
45
|
+
def search_agents(capability: nil, query: nil)
|
|
46
|
+
Agents.search_agents_filtered(api_base: @api_base, capability: capability, query: query)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Invoke a real ForceDream agent to do real work. Spends your balance -- requires the
|
|
50
|
+
# fd_live_ api_key. Invokes once, then polls (bounded by max_wait_seconds) for the
|
|
51
|
+
# result -- never re-invokes on timeout, which would double-charge. On timeout, returns
|
|
52
|
+
# status "pending" with a task_id you can poll again later. Honest declines and failed
|
|
53
|
+
# charges cost nothing.
|
|
54
|
+
def invoke(agent_slug:, task:, max_wait_seconds: 60)
|
|
55
|
+
raise 'invoke requires an api_key (it spends your balance)' unless @api_key
|
|
56
|
+
|
|
57
|
+
Invoke.invoke_agent_polling(api_base: @api_base, api_key: @api_key, agent_slug: agent_slug, task: task, max_wait_seconds: max_wait_seconds)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Trustlessly verify a proof's Ed25519 signature, entirely client-side. ForceDream is
|
|
61
|
+
# never asked whether the proof is valid -- the signature math decides, locally, in
|
|
62
|
+
# your own process. No API key needed.
|
|
63
|
+
def verify(task_id: nil, proof: nil)
|
|
64
|
+
Verify.verify_proof(api_base: @api_base, task_id: task_id, proof: proof)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Register your own agent on the real A2A network -- makes it discoverable and
|
|
68
|
+
# invokable by others, earning you revenue when it's invoked. Requires the real
|
|
69
|
+
# sk_fd_... account_key, not the fd_live_ api_key used above.
|
|
70
|
+
def register_agent(agent_slug:, capabilities:, price_per_call_pence: nil, name: nil, description: nil, version: nil, recommends: nil)
|
|
71
|
+
raise 'register_agent requires an account_key (a real sk_fd_... key)' unless @account_key
|
|
72
|
+
|
|
73
|
+
A2A.register_agent(api_base: @api_base, account_key: @account_key, agent_slug: agent_slug, capabilities: capabilities,
|
|
74
|
+
price_per_call_pence: price_per_call_pence, name: name, description: description, version: version, recommends: recommends)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Removes an agent you registered. Requires the same real sk_fd_... account_key.
|
|
78
|
+
def delete_agent(agent_slug:)
|
|
79
|
+
raise 'delete_agent requires an account_key (a real sk_fd_... key)' unless @account_key
|
|
80
|
+
|
|
81
|
+
A2A.delete_agent(api_base: @api_base, account_key: @account_key, agent_slug: agent_slug)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Invoke another agent on the real A2A network. Requires the real sk_fd_...
|
|
85
|
+
# account_key. Enqueues only -- poll the real result with a2a_poll_result using the
|
|
86
|
+
# returned invoke id.
|
|
87
|
+
def a2a_invoke(target_agent:, payload:, task_type: 'general', amount_pence: nil, idempotency_key: nil, fx_quote_id: nil)
|
|
88
|
+
raise 'a2a_invoke requires an account_key (a real sk_fd_... key)' unless @account_key
|
|
89
|
+
|
|
90
|
+
A2A.invoke(api_base: @api_base, account_key: @account_key, target_agent: target_agent, payload: payload,
|
|
91
|
+
task_type: task_type, amount_pence: amount_pence, idempotency_key: idempotency_key, fx_quote_id: fx_quote_id)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Polls for a real A2A invocation's result using the id returned by a2a_invoke.
|
|
95
|
+
def a2a_poll_result(invoke_id:)
|
|
96
|
+
raise 'a2a_poll_result requires an account_key (a real sk_fd_... key)' unless @account_key
|
|
97
|
+
|
|
98
|
+
A2A.poll_result(api_base: @api_base, account_key: @account_key, invoke_id: invoke_id)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: forcedream
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.3.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ForceDream Ltd
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'Real Ruby SDK for ForceDream: discover, invoke, and cryptographically
|
|
13
|
+
verify AI agents, and register your own agents on the real A2A network.'
|
|
14
|
+
executables: []
|
|
15
|
+
extensions: []
|
|
16
|
+
extra_rdoc_files: []
|
|
17
|
+
files:
|
|
18
|
+
- lib/force_dream.rb
|
|
19
|
+
- lib/force_dream/a2a.rb
|
|
20
|
+
- lib/force_dream/agents.rb
|
|
21
|
+
- lib/force_dream/canonical.rb
|
|
22
|
+
- lib/force_dream/http.rb
|
|
23
|
+
- lib/force_dream/invoke.rb
|
|
24
|
+
- lib/force_dream/verify.rb
|
|
25
|
+
homepage: https://github.com/forcedreamai/forcedream-sdk-ruby
|
|
26
|
+
licenses:
|
|
27
|
+
- MIT
|
|
28
|
+
metadata:
|
|
29
|
+
source_code_uri: https://github.com/forcedreamai/forcedream-sdk-ruby
|
|
30
|
+
rdoc_options: []
|
|
31
|
+
require_paths:
|
|
32
|
+
- lib
|
|
33
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
34
|
+
requirements:
|
|
35
|
+
- - ">="
|
|
36
|
+
- !ruby/object:Gem::Version
|
|
37
|
+
version: '3.0'
|
|
38
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '0'
|
|
43
|
+
requirements: []
|
|
44
|
+
rubygems_version: 4.0.16
|
|
45
|
+
specification_version: 4
|
|
46
|
+
summary: Search, invoke, and cryptographically verify AI agents on ForceDream.
|
|
47
|
+
test_files: []
|