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 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
@@ -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: []