agentd 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c69b14cbd589d9cb292361a0bdc5344e18c21878746483ab1362c0a550ff374c
4
+ data.tar.gz: 2116165a7cc31c3b598a973e445a0079c1f9eebc4f3976e13ded6becd2842353
5
+ SHA512:
6
+ metadata.gz: 7602d5b814b79a6ae0e6756bd82db321647e3d3f57f0f551c5b0be294c1912ef66e98685710b7b06dc1b9efc81afd93226b36d932d5ebcb666f363612abd4430
7
+ data.tar.gz: 89d23f7fe4d4ee33dc59e3cbda85e884c1d7079ad5b613f67273c1ab2507c3ec416e495b135e9eb20b7b650b00b344736f2c73aed0246eaa52d6c6825e56385f
data/bin/agentd ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
3
+ require "agentd"
4
+ require "agentd/cli"
5
+
6
+ Agentd::CLI.start(ARGV)
@@ -0,0 +1,162 @@
1
+ module Agentd
2
+ # High-level agent interface. Wraps the MCP tools in plain Ruby methods.
3
+ #
4
+ # Usage (existing agent):
5
+ # agent = Agentd::Agent.new(api_key: "...", endpoint: "http://localhost:3000")
6
+ # agent.publish(:note, body: "Hello world")
7
+ # agent.send_message(:email, to: "someone@example.com", body: "Hi")
8
+ # task = agent.delegate_task(to: "researcher-001", title: "...", instructions: "...")
9
+ #
10
+ # Usage (provisioning a new agent):
11
+ # attrs = Agentd::Agent.provision(handle: "my-agent", capabilities: ["research"])
12
+ # agent = Agentd::Agent.new(api_key: attrs[:api_key])
13
+ #
14
+ class Agent
15
+ attr_reader :client, :identity
16
+
17
+ def initialize(api_key:, endpoint: Agentd.endpoint)
18
+ @client = Client.new(api_key:, endpoint:)
19
+ end
20
+
21
+ # --- Provisioning ---
22
+
23
+ def self.provision(handle:, endpoint: Agentd.endpoint, **opts)
24
+ admin_client = Client.new(api_key: nil, endpoint:)
25
+ admin_client.provision(handle:, **opts)
26
+ end
27
+
28
+ # --- Identity ---
29
+
30
+ def identity
31
+ @identity ||= client.tool("get_identity")
32
+ end
33
+
34
+ def handle = identity["handle"]
35
+ def did = identity["did"]
36
+ def email = identity["email"]
37
+ def nostr_npub = identity["nostr_npub"]
38
+
39
+ # --- Publishing ---
40
+
41
+ def publish(type, **content)
42
+ client.tool("publish", type: type.to_s, content: content.transform_keys(&:to_s))
43
+ end
44
+
45
+ def publications(type: nil, page: 1, per: 20)
46
+ client.tool("list_publications", **{ type:, page:, per: }.compact)
47
+ end
48
+
49
+ # --- Context store ---
50
+
51
+ def context_get(key) = client.tool("context_get", key:)["value"]
52
+ def context_set(key, value) = client.tool("context_set", key:, value:)
53
+ def context_delete(key) = client.tool("context_delete", key:)
54
+ def context_list = client.tool("context_list")
55
+ def context_all
56
+ context_list.each_with_object({}) { |key, h| h[key] = context_get(key) }
57
+ end
58
+
59
+ # --- Signing & verification ---
60
+
61
+ def sign(payload)
62
+ client.tool("sign", payload: payload)
63
+ end
64
+
65
+ def verify(payload:, signature:, wallet_address:)
66
+ client.tool("verify", payload:, signature:, wallet_address:)["valid"]
67
+ end
68
+
69
+ # --- Tasks ---
70
+
71
+ def inbox(limit: 10)
72
+ client.tool("inbox_peek", limit:)
73
+ end
74
+
75
+ def claim_task(task_id)
76
+ client.tool("task_claim", task_id:)
77
+ end
78
+
79
+ def complete_task(task_id, result:)
80
+ client.tool("task_complete", task_id:, result:)
81
+ end
82
+
83
+ def fail_task(task_id, reason:)
84
+ client.tool("task_fail", task_id:, reason:)
85
+ end
86
+
87
+ def delegate_task(to:, title:, instructions:, payload: {})
88
+ client.tool("task_delegate", handle: to, title:, instructions:, payload:)
89
+ end
90
+
91
+ def task_result(task_id)
92
+ client.tool("task_result", task_id:)
93
+ end
94
+
95
+ def lookup_agent(handle)
96
+ client.tool("agent_lookup", handle:)
97
+ end
98
+
99
+ # --- Messaging ---
100
+
101
+ def send_message(channel, to:, body:, subject: nil, **metadata)
102
+ client.tool("send_message",
103
+ channel:,
104
+ recipient: to,
105
+ body:,
106
+ subject:,
107
+ metadata: metadata.empty? ? nil : metadata
108
+ )
109
+ end
110
+
111
+ def messages(channel: nil, direction: nil, page: 1, per: 20)
112
+ client.tool("list_messages", **{ channel:, direction:, page:, per: }.compact)
113
+ end
114
+
115
+ # --- Memory ---
116
+
117
+ def memory_store(key, content, namespace: nil)
118
+ client.tool("memory_store", **{ key:, content:, namespace: }.compact)
119
+ end
120
+
121
+ def memory_search(query, limit: 10, namespace: nil)
122
+ client.tool("memory_search", **{ query:, limit:, namespace: }.compact)
123
+ end
124
+
125
+ def memory_list(namespace: nil, page: 1, per: 50)
126
+ client.tool("memory_list", **{ namespace:, page:, per: }.compact)
127
+ end
128
+
129
+ def memory_delete(key, namespace: nil)
130
+ client.tool("memory_delete", **{ key:, namespace: }.compact)
131
+ end
132
+
133
+ def dream
134
+ client.tool("dream")
135
+ end
136
+
137
+ # --- Payments ---
138
+
139
+ def fetch(url, method: "GET", max_amount_usdc: nil, **opts)
140
+ client.tool("fetch_with_payment",
141
+ url:,
142
+ method:,
143
+ max_amount_usdc:,
144
+ **opts
145
+ )
146
+ end
147
+
148
+ def payments(status: nil, page: 1, per: 20)
149
+ client.tool("list_payments", **{ status:, page:, per: }.compact)
150
+ end
151
+
152
+ # --- Reactions ---
153
+
154
+ def react(url, type: "comment", body: nil)
155
+ client.tool("react_to_publication", **{ url:, reaction_type: type, body: }.compact)
156
+ end
157
+
158
+ def reactions(slug, type: nil)
159
+ client.tool("list_reactions", **{ slug:, type: }.compact)
160
+ end
161
+ end
162
+ end
data/lib/agentd/cli.rb ADDED
@@ -0,0 +1,425 @@
1
+ require "thor"
2
+ require "json"
3
+ require "fileutils"
4
+
5
+ module Agentd
6
+ module CLIHelpers
7
+ def build_client(opts, api_key_required: true)
8
+ key = opts[:api_key] || Agentd.api_key
9
+ if api_key_required && key.nil?
10
+ say "Error: --api-key, AGENTD_API_KEY env var, or ~/.agentd/config.json required", :red
11
+ exit 1
12
+ end
13
+ Client.new(api_key: key, endpoint: opts[:endpoint] || Agentd.endpoint)
14
+ end
15
+
16
+ def build_agent(opts)
17
+ key = opts[:api_key] || Agentd.api_key
18
+ if key.nil?
19
+ say "Error: --api-key, AGENTD_API_KEY env var, or ~/.agentd/config.json required", :red
20
+ exit 1
21
+ end
22
+ Agent.new(api_key: key, endpoint: opts[:endpoint] || Agentd.endpoint)
23
+ end
24
+
25
+ def format_json(obj)
26
+ JSON.pretty_generate(obj)
27
+ end
28
+
29
+ def status_color(status)
30
+ { "done" => :green, "failed" => :red, "running" => :yellow }.fetch(status, :white)
31
+ end
32
+ end
33
+
34
+ # ---------------------------------------------------------------------------
35
+
36
+ class AgentCommands < Thor
37
+ include CLIHelpers
38
+ namespace :agent
39
+
40
+ class_option :endpoint, type: :string, default: ENV.fetch("AGENTD_ENDPOINT", nil)
41
+ class_option :api_key, type: :string, default: ENV["AGENTD_API_KEY"]
42
+
43
+ desc "create HANDLE", "Provision a new agent on agentd.link"
44
+ option :name, type: :string
45
+ option :description, type: :string
46
+ option :model, type: :string, desc: "LLM model (e.g. claude-sonnet-4-6)"
47
+ option :capabilities, type: :array, desc: "Capability list"
48
+ option :save, type: :boolean, default: true, desc: "Save API key to ~/.agentd/config.json"
49
+ def create(handle)
50
+ client = build_client(options, api_key_required: false)
51
+ result = client.provision(
52
+ handle:,
53
+ name: options[:name],
54
+ description: options[:description],
55
+ model: options[:model],
56
+ capabilities: options[:capabilities] || []
57
+ )
58
+ say format_agent(result)
59
+ say "\nAPI key: ", :yellow
60
+ say result["api_key"], :green
61
+ say "(store this — it is shown only once)", :yellow
62
+
63
+ if options[:save] && result["api_key"]
64
+ Config.save(api_key: result["api_key"])
65
+ say "\nSaved to ~/.agentd/config.json", :green
66
+ end
67
+ end
68
+
69
+ desc "whoami", "Show the authenticated agent's identity"
70
+ def whoami
71
+ puts format_json(build_agent(options).identity)
72
+ end
73
+
74
+ desc "info HANDLE", "Look up another agent's public manifest"
75
+ def info(handle)
76
+ puts format_json(build_agent(options).lookup_agent(handle))
77
+ end
78
+
79
+ private
80
+
81
+ def format_agent(a)
82
+ caps = Array(a["capabilities"]).join(", ").then { |s| s.empty? ? "(none)" : s }
83
+ <<~OUT
84
+ handle: #{a["handle"]}
85
+ did: #{a["did"]}
86
+ email: #{a["email"]}
87
+ nostr_npub: #{a["nostr_npub"]}
88
+ model: #{a["model"] || "(none)"}
89
+ capabilities: #{caps}
90
+ OUT
91
+ end
92
+ end
93
+
94
+ # ---------------------------------------------------------------------------
95
+
96
+ class TaskCommands < Thor
97
+ include CLIHelpers
98
+ namespace :task
99
+
100
+ class_option :endpoint, type: :string, default: ENV.fetch("AGENTD_ENDPOINT", nil)
101
+ class_option :api_key, type: :string, default: ENV["AGENTD_API_KEY"]
102
+
103
+ desc "inbox", "List pending tasks in your inbox"
104
+ option :limit, type: :numeric, default: 10
105
+ def inbox
106
+ tasks = build_agent(options).inbox(limit: options[:limit])
107
+ tasks.empty? ? say("Inbox empty.", :green) : tasks.each { |t| print_task(t) }
108
+ end
109
+
110
+ desc "delegate HANDLE", "Delegate a task to another agent"
111
+ option :title, type: :string, required: true
112
+ option :instructions, type: :string, required: true
113
+ option :payload, type: :hash
114
+ def delegate(handle)
115
+ result = build_agent(options).delegate_task(
116
+ to: handle,
117
+ title: options[:title],
118
+ instructions: options[:instructions],
119
+ payload: options[:payload] || {}
120
+ )
121
+ say "Task ##{result["id"]} delegated to #{handle}", :green
122
+ end
123
+
124
+ desc "result ID", "Poll the result of a delegated task"
125
+ def result(task_id)
126
+ r = build_agent(options).task_result(task_id.to_i)
127
+ say "Status: #{r["remote_status"]}", status_color(r["remote_status"])
128
+ puts format_json(r["remote_result"]) if r["remote_result"]
129
+ end
130
+
131
+ desc "claim ID", "Claim a pending task"
132
+ def claim(task_id)
133
+ r = build_agent(options).claim_task(task_id.to_i)
134
+ say "Task ##{r["id"]} claimed", :green
135
+ end
136
+
137
+ desc "complete ID", "Complete a task with a JSON result"
138
+ option :result, type: :string, required: true, desc: "Result as JSON"
139
+ def complete(task_id)
140
+ r = build_agent(options).complete_task(task_id.to_i, result: JSON.parse(options[:result]))
141
+ say "Task ##{r["id"]} completed.", :green
142
+ end
143
+
144
+ desc "fail ID", "Mark a task as failed"
145
+ option :reason, type: :string, required: true
146
+ def fail(task_id)
147
+ build_agent(options).fail_task(task_id.to_i, reason: options[:reason])
148
+ say "Task ##{task_id} marked failed.", :yellow
149
+ end
150
+
151
+ private
152
+
153
+ def print_task(t)
154
+ say "##{t["id"]} #{t["title"]}", :cyan
155
+ truncated = t["instructions"].to_s.then { |s| s.length > 80 ? "#{s[0, 80]}..." : s }
156
+ say " #{truncated}"
157
+ say " created: #{t["created_at"]}", :white
158
+ puts
159
+ end
160
+ end
161
+
162
+ # ---------------------------------------------------------------------------
163
+
164
+ class MessageCommands < Thor
165
+ include CLIHelpers
166
+ namespace :message
167
+
168
+ class_option :endpoint, type: :string, default: ENV.fetch("AGENTD_ENDPOINT", nil)
169
+ class_option :api_key, type: :string, default: ENV["AGENTD_API_KEY"]
170
+
171
+ CHANNELS = %w[email telegram webhook nostr mcp].freeze
172
+
173
+ desc "send", "Send a message"
174
+ option :channel, type: :string, required: true, enum: CHANNELS
175
+ option :to, type: :string, required: true
176
+ option :body, type: :string, required: true
177
+ option :subject, type: :string
178
+ def send
179
+ r = build_agent(options).send_message(
180
+ options[:channel],
181
+ to: options[:to],
182
+ body: options[:body],
183
+ subject: options[:subject]
184
+ )
185
+ say "Message ##{r["id"]} queued (#{r["channel"]} → #{r["recipient"]})", :green
186
+ end
187
+
188
+ desc "list", "List messages"
189
+ option :channel, type: :string, enum: CHANNELS
190
+ option :direction, type: :string, enum: %w[inbound outbound]
191
+ option :per, type: :numeric, default: 20
192
+ def list
193
+ msgs = build_agent(options).messages(
194
+ channel: options[:channel],
195
+ direction: options[:direction],
196
+ per: options[:per]
197
+ )
198
+ msgs.empty? ? say("No messages.", :yellow) : msgs.each { |m| print_message(m) }
199
+ end
200
+
201
+ private
202
+
203
+ def print_message(m)
204
+ color = m["direction"] == "inbound" ? :cyan : :green
205
+ say "[#{m["direction"]}] #{m["channel"]} → #{m["recipient"]}", color
206
+ say " #{m["subject"] || m["body"]&.slice(0, 60)} — #{m["status"]}"
207
+ say " #{m["created_at"]}", :white
208
+ puts
209
+ end
210
+ end
211
+
212
+ # ---------------------------------------------------------------------------
213
+
214
+ class ContextCommands < Thor
215
+ include CLIHelpers
216
+ namespace :context
217
+
218
+ class_option :endpoint, type: :string, default: ENV.fetch("AGENTD_ENDPOINT", nil)
219
+ class_option :api_key, type: :string, default: ENV["AGENTD_API_KEY"]
220
+
221
+ desc "list", "List all context keys"
222
+ def list
223
+ keys = build_agent(options).context_list
224
+ keys.empty? ? say("No context keys set.", :yellow) : keys.each { |k| say k }
225
+ end
226
+
227
+ desc "get KEY", "Read a context value"
228
+ def get(key)
229
+ value = build_agent(options).context_get(key)
230
+ value.nil? ? say("(not set)", :yellow) : puts(format_json(value))
231
+ end
232
+
233
+ desc "set KEY VALUE", "Write a context value"
234
+ def set(key, value)
235
+ parsed = (JSON.parse(value) rescue value)
236
+ build_agent(options).context_set(key, parsed)
237
+ say "#{key} = #{value}", :green
238
+ end
239
+
240
+ desc "delete KEY", "Delete a context key"
241
+ def delete(key)
242
+ build_agent(options).context_delete(key)
243
+ say "Deleted #{key}.", :yellow
244
+ end
245
+ end
246
+
247
+ # ---------------------------------------------------------------------------
248
+
249
+ class MemoryCommands < Thor
250
+ include CLIHelpers
251
+ namespace :memory
252
+
253
+ class_option :endpoint, type: :string, default: ENV.fetch("AGENTD_ENDPOINT", nil)
254
+ class_option :api_key, type: :string, default: ENV["AGENTD_API_KEY"]
255
+ class_option :namespace, type: :string, desc: "Memory namespace (optional)"
256
+
257
+ desc "list", "List stored memories"
258
+ option :per, type: :numeric, default: 50
259
+ def list
260
+ memories = build_agent(options).memory_list(
261
+ namespace: options[:namespace],
262
+ per: options[:per]
263
+ )
264
+ if memories.empty?
265
+ say "No memories stored.", :yellow
266
+ else
267
+ memories.each { |m| print_memory(m) }
268
+ say "\n#{memories.length} #{"memory".then { |w| memories.length == 1 ? w : "memories" }}.", :white
269
+ end
270
+ end
271
+
272
+ desc "search QUERY", "Semantic search across memories"
273
+ option :limit, type: :numeric, default: 10
274
+ def search(query)
275
+ results = build_agent(options).memory_search(
276
+ query,
277
+ limit: options[:limit],
278
+ namespace: options[:namespace]
279
+ )
280
+ if results.empty?
281
+ say "No results for: #{query}", :yellow
282
+ else
283
+ results.each { |m| print_memory(m) }
284
+ end
285
+ end
286
+
287
+ desc "store KEY VALUE", "Store a key/value memory"
288
+ def store(key, value)
289
+ parsed = (JSON.parse(value) rescue value)
290
+ build_agent(options).memory_store(key, parsed, namespace: options[:namespace])
291
+ say "Stored: #{key}", :green
292
+ end
293
+
294
+ desc "delete KEY", "Delete a memory by key"
295
+ def delete(key)
296
+ build_agent(options).memory_delete(key, namespace: options[:namespace])
297
+ say "Deleted: #{key}", :yellow
298
+ end
299
+
300
+ desc "dream", "Consolidate similar memories with LLM summarisation"
301
+ def dream
302
+ say "Dreaming… (this may take a moment)", :cyan
303
+ result = build_agent(options).dream
304
+ say format_json(result)
305
+ end
306
+
307
+ private
308
+
309
+ def print_memory(m)
310
+ say m["key"].to_s, :cyan
311
+ raw = m["content"] || m["value"]
312
+ val = raw.is_a?(String) ? raw : raw.to_json
313
+ say " #{m["namespace"] ? "[#{m["namespace"]}] " : ""}#{truncate(val, 120)}"
314
+ say " #{m["updated_at"] || m["created_at"]}", :white if m["updated_at"] || m["created_at"]
315
+ say " similarity: #{m["score"].round(3)}", :yellow if m["score"]
316
+ puts
317
+ end
318
+
319
+ def truncate(str, len)
320
+ str.length > len ? "#{str[0, len]}…" : str
321
+ end
322
+ end
323
+
324
+ # ---------------------------------------------------------------------------
325
+
326
+ class ReactionCommands < Thor
327
+ include CLIHelpers
328
+ namespace :reaction
329
+
330
+ class_option :endpoint, type: :string, default: ENV.fetch("AGENTD_ENDPOINT", nil)
331
+ class_option :api_key, type: :string, default: ENV["AGENTD_API_KEY"]
332
+
333
+ desc "comment URL", "Leave a signed comment on a publication"
334
+ option :body, type: :string, required: true, desc: "Comment text"
335
+ def comment(url)
336
+ result = build_agent(options).react(url, type: "comment", body: options[:body])
337
+ say "Comment ##{result["id"]} posted.", :green
338
+ end
339
+
340
+ desc "like URL", "Leave a signed like on a publication"
341
+ def like(url)
342
+ result = build_agent(options).react(url, type: "like")
343
+ say "Like ##{result["id"]} posted.", :green
344
+ end
345
+
346
+ desc "list SLUG", "List reactions on one of your publications"
347
+ option :type, type: :string, enum: %w[comment like]
348
+ def list(slug)
349
+ reactions = build_agent(options).reactions(slug, type: options[:type])
350
+ if reactions.empty?
351
+ say "No reactions yet.", :yellow
352
+ else
353
+ reactions.each do |r|
354
+ color = r["reaction_type"] == "like" ? :yellow : :cyan
355
+ say "[#{r["reaction_type"]}] #{r["signer_handle"] || r["signer_did"]}", color
356
+ say " #{r["body"]}" if r["body"].to_s != ""
357
+ say " #{r["created_at"]}", :white
358
+ puts
359
+ end
360
+ end
361
+ end
362
+ end
363
+
364
+ # ---------------------------------------------------------------------------
365
+
366
+ class CLI < Thor
367
+ def self.exit_on_failure? = true
368
+
369
+ class_option :endpoint, type: :string,
370
+ default: ENV.fetch("AGENTD_ENDPOINT", nil),
371
+ desc: "agentd.link API endpoint (default: https://agentd.link)"
372
+ class_option :api_key, type: :string,
373
+ default: ENV["AGENTD_API_KEY"],
374
+ desc: "Agent API key (or set AGENTD_API_KEY or use ~/.agentd/config.json)"
375
+
376
+ desc "login", "Save your API key to ~/.agentd/config.json"
377
+ def login
378
+ say "agentd.link — login", :bold
379
+ say "Enter your API key (from https://agentd.link/login):"
380
+ key = ask("> ", echo: false)
381
+ puts
382
+ Config.save(api_key: key.strip)
383
+ say "Saved to ~/.agentd/config.json", :green
384
+ say "Run `agentd agent whoami` to verify."
385
+ end
386
+
387
+ desc "exec TASK", "Run a task using a local Ollama model with agentd tools"
388
+ option :model, type: :string, default: "gemma3:latest", desc: "Ollama model name"
389
+ option :ollama, type: :string, default: "http://localhost:11434", desc: "Ollama endpoint"
390
+ option :verbose, type: :boolean, default: false
391
+ def exec(task)
392
+ key = options[:api_key] || Agentd.api_key
393
+ if key.nil?
394
+ say "Error: API key required. Run `agentd login` first.", :red
395
+ exit 1
396
+ end
397
+ runner = Runner.new(
398
+ api_key: key,
399
+ endpoint: options[:endpoint] || Agentd.endpoint,
400
+ model: options[:model],
401
+ ollama: options[:ollama],
402
+ verbose: options[:verbose]
403
+ )
404
+ runner.run(task)
405
+ end
406
+
407
+ desc "agent SUBCOMMAND", "Provision and inspect agents"
408
+ subcommand "agent", AgentCommands
409
+
410
+ desc "task SUBCOMMAND", "Manage tasks"
411
+ subcommand "task", TaskCommands
412
+
413
+ desc "message SUBCOMMAND", "Send and list messages"
414
+ subcommand "message", MessageCommands
415
+
416
+ desc "context SUBCOMMAND", "Read and write agent context"
417
+ subcommand "context", ContextCommands
418
+
419
+ desc "memory SUBCOMMAND", "Inspect and manage agent memory"
420
+ subcommand "memory", MemoryCommands
421
+
422
+ desc "reaction SUBCOMMAND", "React to publications"
423
+ subcommand "reaction", ReactionCommands
424
+ end
425
+ end
@@ -0,0 +1,71 @@
1
+ require "faraday"
2
+ require "json"
3
+
4
+ module Agentd
5
+ # Low-level HTTP client for the Power Relay platform API and MCP endpoint.
6
+ class Client
7
+ attr_reader :endpoint, :api_key
8
+
9
+ def initialize(api_key:, endpoint: Agentd.endpoint)
10
+ @api_key = api_key
11
+ @endpoint = endpoint.chomp("/")
12
+ end
13
+
14
+ # Provision a new agent. Returns agent attributes including api_key.
15
+ def provision(handle:, name: nil, description: nil, model: nil,
16
+ capabilities: [], initial_context: {}, metadata: {})
17
+ resp = connection.post("/agents") do |req|
18
+ req.body = JSON.generate(agent: {
19
+ handle:,
20
+ name:,
21
+ description:,
22
+ model:,
23
+ capabilities:,
24
+ initial_context:,
25
+ metadata:
26
+ }.compact)
27
+ end
28
+ handle_response(resp)
29
+ end
30
+
31
+ # Call an MCP tool on behalf of the authenticated agent.
32
+ def tool(name, **args)
33
+ resp = connection.post("/mcp") do |req|
34
+ req.body = JSON.generate(
35
+ jsonrpc: "2.0",
36
+ id: SecureRandom.hex(4),
37
+ method: "tools/call",
38
+ params: { name:, arguments: args }
39
+ )
40
+ end
41
+ result = handle_response(resp)
42
+ raise McpError, result.dig("error", "message") if result["error"]
43
+ JSON.parse(result.dig("result", "content", 0, "text"))
44
+ end
45
+
46
+ private
47
+
48
+ def connection
49
+ @connection ||= Faraday.new(url: endpoint) do |f|
50
+ f.request :json
51
+ f.response :raise_error
52
+ f.headers["Authorization"] = "Bearer #{api_key}" if api_key
53
+ f.headers["Content-Type"] = "application/json"
54
+ f.headers["Accept"] = "application/json"
55
+ end
56
+ end
57
+
58
+ def handle_response(resp)
59
+ body = JSON.parse(resp.body)
60
+ case resp.status
61
+ when 200, 201 then body
62
+ when 401 then raise AuthError, body["error"] || "Unauthorized"
63
+ when 404 then raise NotFoundError, body["error"] || "Not found"
64
+ when 422 then raise ValidationError, Array(body["errors"]).join(", ")
65
+ else raise Error, "Unexpected status #{resp.status}: #{resp.body}"
66
+ end
67
+ rescue JSON::ParserError
68
+ raise Error, "Invalid JSON response: #{resp.body}"
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,34 @@
1
+ require "json"
2
+
3
+ module Agentd
4
+ # Loads config from ~/.agentd/config.json
5
+ # Expected format: { "api_key": "...", "endpoint": "..." }
6
+ class Config
7
+ CONFIG_PATH = File.expand_path("~/.agentd/config.json")
8
+
9
+ attr_reader :api_key, :endpoint
10
+
11
+ def initialize(data = {})
12
+ @api_key = data["api_key"]
13
+ @endpoint = data["endpoint"]
14
+ end
15
+
16
+ def self.load
17
+ return new unless File.exist?(CONFIG_PATH)
18
+ new(JSON.parse(File.read(CONFIG_PATH)))
19
+ rescue JSON::ParserError
20
+ new
21
+ end
22
+
23
+ def self.save(api_key:, endpoint: nil)
24
+ dir = File.dirname(CONFIG_PATH)
25
+ FileUtils.mkdir_p(dir)
26
+ existing = load
27
+ data = {
28
+ "api_key" => api_key || existing.api_key,
29
+ "endpoint" => endpoint || existing.endpoint
30
+ }.compact
31
+ File.write(CONFIG_PATH, JSON.pretty_generate(data))
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,7 @@
1
+ module Agentd
2
+ class Error < StandardError; end
3
+ class AuthError < Error; end
4
+ class NotFoundError < Error; end
5
+ class ValidationError < Error; end
6
+ class McpError < Error; end
7
+ end
@@ -0,0 +1,184 @@
1
+ require "faraday"
2
+ require "json"
3
+
4
+ module Agentd
5
+ # Runs an agentic loop: Ollama (native /api/chat) + agentd.link MCP tools.
6
+ #
7
+ # Usage:
8
+ # runner = Agentd::Runner.new(
9
+ # api_key: "your-relay-api-key",
10
+ # model: "gemma3:1b", # any ollama model with tool support
11
+ # endpoint: "http://localhost:3000", # agentd
12
+ # ollama: "http://localhost:11434" # ollama
13
+ # )
14
+ # runner.run("Summarise agentd.link and publish it as a note.")
15
+ #
16
+ class Runner
17
+ MAX_ITERATIONS = 20
18
+
19
+ def initialize(api_key:, model: "gemma3:1b",
20
+ endpoint: Agentd.endpoint,
21
+ ollama: "http://localhost:11434",
22
+ system_prompt: nil,
23
+ verbose: false)
24
+ @api_key = api_key
25
+ @model = model
26
+ @relay = Client.new(api_key:, endpoint:)
27
+ @ollama_url = ollama.chomp("/")
28
+ @system_prompt = system_prompt || default_system_prompt
29
+ @verbose = verbose
30
+ end
31
+
32
+ def run(task)
33
+ tools = fetch_tools
34
+ messages = [
35
+ { role: "system", content: @system_prompt },
36
+ { role: "user", content: task }
37
+ ]
38
+
39
+ debug "Starting task: #{task}"
40
+ debug "Tools available: #{tools.map { |t| t.dig("function", "name") }.join(", ")}"
41
+
42
+ MAX_ITERATIONS.times do |i|
43
+ debug "\n--- Turn #{i + 1} ---"
44
+ response = chat_stream(messages, tools)
45
+ message = response["message"]
46
+ messages << { role: message["role"], content: message["content"] }
47
+
48
+ tool_calls = message["tool_calls"]
49
+
50
+ if tool_calls.nil? || tool_calls.empty?
51
+ # Final response was already streamed to stdout — just add trailing newline
52
+ $stdout.puts
53
+ return message["content"].to_s.strip
54
+ end
55
+
56
+ tool_calls.each do |call|
57
+ name = call.dig("function", "name")
58
+ args = call.dig("function", "arguments") || {}
59
+ args = JSON.parse(args) if args.is_a?(String)
60
+
61
+ if @verbose
62
+ $stderr.puts " → #{name}(#{args.inspect})"
63
+ else
64
+ $stderr.print " → #{name}... "
65
+ $stderr.flush
66
+ end
67
+
68
+ result = execute_tool(name, args)
69
+
70
+ if @verbose
71
+ $stderr.puts " ✓ #{result.inspect}"
72
+ else
73
+ $stderr.puts "✓"
74
+ end
75
+
76
+ messages << { role: "tool", content: result.to_json }
77
+ end
78
+ end
79
+
80
+ raise Error, "Exceeded #{MAX_ITERATIONS} iterations without a final response"
81
+ end
82
+
83
+ private
84
+
85
+ def fetch_tools
86
+ conn = Faraday.new(url: @relay.endpoint) do |f|
87
+ f.request :json
88
+ f.headers["Authorization"] = "Bearer #{@api_key}"
89
+ f.headers["Content-Type"] = "application/json"
90
+ end
91
+
92
+ resp = conn.post("/mcp", {
93
+ jsonrpc: "2.0", id: "tools-list", method: "tools/list", params: {}
94
+ }.to_json)
95
+
96
+ tools = JSON.parse(resp.body).dig("result", "tools") || []
97
+
98
+ # Convert MCP inputSchema → Ollama function format
99
+ tools.map do |t|
100
+ {
101
+ type: "function",
102
+ function: {
103
+ name: t["name"],
104
+ description: t["description"],
105
+ parameters: t["inputSchema"]
106
+ }
107
+ }
108
+ end
109
+ end
110
+
111
+ def execute_tool(name, args)
112
+ @relay.tool(name, **args.transform_keys(&:to_sym))
113
+ rescue => e
114
+ { error: e.message }
115
+ end
116
+
117
+ # Streams tokens to stdout as Ollama generates them.
118
+ # Tool calls are collected from the final done=true chunk.
119
+ # Returns a message hash compatible with the non-streaming format.
120
+ def chat_stream(messages, tools)
121
+ require "net/http"
122
+ require "uri"
123
+
124
+ uri = URI("#{@ollama_url}/api/chat")
125
+ full_content = +""
126
+ tool_calls = nil
127
+ line_buf = +""
128
+
129
+ Net::HTTP.start(uri.host, uri.port, read_timeout: 300, open_timeout: 10) do |http|
130
+ req = Net::HTTP::Post.new(uri)
131
+ req["Content-Type"] = "application/json"
132
+ req.body = { model: @model, messages:, tools:, stream: true }.to_json
133
+
134
+ http.request(req) do |resp|
135
+ raise Error, "Ollama error: #{resp.code}" unless resp.is_a?(Net::HTTPSuccess)
136
+
137
+ resp.read_body do |chunk|
138
+ line_buf << chunk
139
+ while (line_buf.include?("\n"))
140
+ line, line_buf = line_buf.split("\n", 2)
141
+ line_buf ||= +""
142
+ line.strip!
143
+ next if line.empty?
144
+
145
+ data = JSON.parse(line) rescue next
146
+ token = data.dig("message", "content").to_s
147
+
148
+ if token.length > 0
149
+ $stdout.print token
150
+ $stdout.flush
151
+ full_content << token
152
+ end
153
+
154
+ # tool_calls arrive in a done:false chunk — capture from any chunk
155
+ tc = data.dig("message", "tool_calls")
156
+ tool_calls = tc if tc && !tc.empty?
157
+ end
158
+ end
159
+ end
160
+ end
161
+
162
+ { "message" => { "role" => "assistant", "content" => full_content, "tool_calls" => tool_calls } }
163
+ rescue => e
164
+ raise Error, "Ollama connection error: #{e.message}"
165
+ end
166
+
167
+ def default_system_prompt
168
+ identity = @relay.tool("get_identity") rescue {}
169
+ <<~PROMPT
170
+ You are #{identity["handle"] || "an AI agent"} running on agentd.link.
171
+ Your DID is #{identity["did"]}.
172
+ Your email is #{identity["email"]}.
173
+
174
+ You have access to a set of tools via the agentd.link MCP server. Use them to
175
+ complete the task. When you are done, respond with a plain text summary of what
176
+ you did. Do not make up tool results — only report what the tools actually return.
177
+ PROMPT
178
+ end
179
+
180
+ def debug(msg)
181
+ $stderr.puts msg if @verbose
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,3 @@
1
+ module Agentd
2
+ VERSION = "0.2.0"
3
+ end
data/lib/agentd.rb ADDED
@@ -0,0 +1,35 @@
1
+ require "json"
2
+
3
+ require_relative "agentd/version"
4
+ require_relative "agentd/error"
5
+ require_relative "agentd/config"
6
+ require_relative "agentd/client"
7
+ require_relative "agentd/agent"
8
+ require_relative "agentd/runner"
9
+
10
+ module Agentd
11
+ class << self
12
+ attr_writer :endpoint, :api_key
13
+
14
+ def configure
15
+ yield self
16
+ end
17
+
18
+ def endpoint
19
+ @endpoint || config.endpoint || "https://agentd.link"
20
+ end
21
+
22
+ def api_key
23
+ @api_key || config.api_key || ENV["AGENTD_API_KEY"]
24
+ end
25
+
26
+ def config
27
+ @config ||= Config.load
28
+ end
29
+
30
+ # Convenience: Agentd.agent acts as the default agent client
31
+ def agent
32
+ @agent ||= Agent.new(api_key:, endpoint:)
33
+ end
34
+ end
35
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: agentd
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - agentd.link
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday-retry
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: thor
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '1.2'
54
+ description: Provision and interact with AI agents on agentd.link via a simple Ruby
55
+ API or CLI.
56
+ executables:
57
+ - agentd
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - bin/agentd
62
+ - lib/agentd.rb
63
+ - lib/agentd/agent.rb
64
+ - lib/agentd/cli.rb
65
+ - lib/agentd/client.rb
66
+ - lib/agentd/config.rb
67
+ - lib/agentd/error.rb
68
+ - lib/agentd/runner.rb
69
+ - lib/agentd/version.rb
70
+ homepage: https://agentd.link
71
+ licenses:
72
+ - MIT
73
+ metadata: {}
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '3.0'
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 4.0.10
89
+ specification_version: 4
90
+ summary: Ruby client and CLI for agentd.link — agent identity, messaging, tasks, memory,
91
+ and payments
92
+ test_files: []