ctxmesh 0.0.1.pre → 0.1.0.pre.beta.3
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/README.md +51 -7
- data/lib/ctxmesh/config.rb +77 -0
- data/lib/ctxmesh/errors.rb +36 -0
- data/lib/ctxmesh/version.rb +6 -0
- data/lib/ctxmesh.rb +260 -4
- metadata +20 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce25dc8c1240107783738a291b4288ceb2dc75420130bfff83d42613af875838
|
|
4
|
+
data.tar.gz: edbf4b51e49bcd272cde852c9ae84654d09d7b416925cdbe35ab4f835f0a7f31
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d7c147f0e15b649eedb21b2beb00eb17cafb4f2d6b68884733765c48ffd382225f650b6590a0c19b90911b4a3519734c24d6f4c4441ed481a7b23311714efb4b
|
|
7
|
+
data.tar.gz: 06c25966b09429cc589a27a56d95538ee52780ef9e42f134c2b0932cd5154f9e686112fa910d55411cadee945bf493e45325804af177d8973e02c17a4e5eb18f
|
data/README.md
CHANGED
|
@@ -1,11 +1,55 @@
|
|
|
1
|
-
# ctxmesh
|
|
1
|
+
# ctxmesh — Ruby SDK
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Typed clients for agents running on [ctxmesh](https://ctxmesh.github.io), the Kubernetes-native
|
|
4
|
+
control plane for AI agents.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
Your agent runs in a pod beside the platform's sidecars. This gem is the typed way to reach
|
|
7
|
+
them — conversation memory, long-term memory, knowledge bases, skills, feedback, agent-to-agent
|
|
8
|
+
calls, delegation and handoff.
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
**Your code never holds credentials.** Endpoints and identity arrive in the environment the
|
|
11
|
+
platform injects, so there is no API key to manage and no base URL to configure.
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
```ruby
|
|
14
|
+
gem "ctxmesh"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Use it
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
require "ctxmesh"
|
|
21
|
+
|
|
22
|
+
cx = Ctxmesh::Client.from_env # reads MEMORY_PORT, CONVERSATION_ID, …
|
|
23
|
+
|
|
24
|
+
cx.memory_append(role: "user", content: "What changed in the deploy?")
|
|
25
|
+
history = cx.memory_get
|
|
26
|
+
|
|
27
|
+
cx.remember("The customer prefers email.", { "topic" => "prefs" })
|
|
28
|
+
facts = cx.search_agent("contact preference", top_k: 5)
|
|
29
|
+
|
|
30
|
+
hits = cx.knowledge_search("rollback procedure", top_k: 5)
|
|
31
|
+
cx.feedback(trace_id, "helpfulness", 1.0, "clear")
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`Ctxmesh::Client.from_env` raises `NotInPodError` outside the platform.
|
|
35
|
+
|
|
36
|
+
## Errors say which kind of "no" it was
|
|
37
|
+
|
|
38
|
+
- **`NotWiredError`** — the platform did not grant this capability. The port is absent because
|
|
39
|
+
nothing is listening; a configuration answer, not a failure.
|
|
40
|
+
- **`DeniedError`** — a 403. The plane understood the call and refused it (a guardrail, a
|
|
41
|
+
budget, the delegate fence), and it carries the reason.
|
|
42
|
+
- **`ApiError`** — any other non-2xx, with the body.
|
|
43
|
+
|
|
44
|
+
## No runtime dependencies
|
|
45
|
+
|
|
46
|
+
`net/http` and the stdlib `json`. A gem dependency in an SDK becomes a version conflict in every
|
|
47
|
+
application that already has it.
|
|
48
|
+
|
|
49
|
+
## Conformance tier
|
|
50
|
+
|
|
51
|
+
**plane-client** — every launcher route is reachable here. The managed agent loop and
|
|
52
|
+
model client are authoring-tier and live in the Python and TypeScript SDKs. Every capability is
|
|
53
|
+
also a plain HTTP endpoint, so this gem is convenience, never a requirement.
|
|
54
|
+
|
|
55
|
+
Apache-2.0.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ctxmesh
|
|
4
|
+
# The resolved plane for one agent process.
|
|
5
|
+
class Config
|
|
6
|
+
DEFAULT_MEMORY_PORT = 2998
|
|
7
|
+
DEFAULT_FEEDBACK_PORT = 2995
|
|
8
|
+
DEFAULT_AMP_PORT = 2997
|
|
9
|
+
DEFAULT_DELEGATE_PORT = 2994
|
|
10
|
+
|
|
11
|
+
attr_reader :memory_port, :feedback_port, :amp_port, :delegate_port, :agent_name, :conversation_id
|
|
12
|
+
|
|
13
|
+
def initialize(memory_port:, feedback_port:, amp_port:, delegate_port: DEFAULT_DELEGATE_PORT,
|
|
14
|
+
agent_name: "", conversation_id: "",
|
|
15
|
+
memory_wired: true, feedback_wired: true, long_term_enabled: true,
|
|
16
|
+
knowledge_enabled: true)
|
|
17
|
+
@memory_port = memory_port
|
|
18
|
+
@feedback_port = feedback_port
|
|
19
|
+
@amp_port = amp_port
|
|
20
|
+
@delegate_port = delegate_port
|
|
21
|
+
@agent_name = agent_name
|
|
22
|
+
@conversation_id = conversation_id
|
|
23
|
+
@memory_wired = memory_wired
|
|
24
|
+
@feedback_wired = feedback_wired
|
|
25
|
+
@long_term_enabled = long_term_enabled
|
|
26
|
+
@knowledge_enabled = knowledge_enabled
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# False when MEMORY_PORT was absent: the platform did not grant memory to this agent.
|
|
30
|
+
def memory_wired? = @memory_wired
|
|
31
|
+
def feedback_wired? = @feedback_wired
|
|
32
|
+
def long_term_enabled? = @long_term_enabled
|
|
33
|
+
def knowledge_enabled? = @knowledge_enabled
|
|
34
|
+
|
|
35
|
+
def memory_base = "http://127.0.0.1:#{@memory_port}"
|
|
36
|
+
def feedback_base = "http://127.0.0.1:#{@feedback_port}"
|
|
37
|
+
def amp_base = "http://127.0.0.1:#{@amp_port}"
|
|
38
|
+
# Its OWN listener. /delegate and /handoff on the memory port are a 404.
|
|
39
|
+
def delegate_base = "http://127.0.0.1:#{@delegate_port}"
|
|
40
|
+
|
|
41
|
+
# Reads the launcher environment.
|
|
42
|
+
def self.from_env(env = ENV)
|
|
43
|
+
in_pod = %w[MEMORY_PORT FEEDBACK_PORT AGENT_NAME MODEL_GATEWAY_URL].any? { |k| env.key?(k) }
|
|
44
|
+
raise NotInPodError, "ctxmesh: not running in a ctxmesh pod (no launcher environment)" unless in_pod
|
|
45
|
+
|
|
46
|
+
mem, mem_set = port(env, "MEMORY_PORT", DEFAULT_MEMORY_PORT)
|
|
47
|
+
fb, fb_set = port(env, "FEEDBACK_PORT", DEFAULT_FEEDBACK_PORT)
|
|
48
|
+
# A2A_PORT is what the launcher publishes; AMP_PORT was invented.
|
|
49
|
+
amp, = port(env, "A2A_PORT", DEFAULT_AMP_PORT)
|
|
50
|
+
del, = port(env, "DELEGATE_PORT", DEFAULT_DELEGATE_PORT)
|
|
51
|
+
|
|
52
|
+
new(
|
|
53
|
+
memory_port: mem, feedback_port: fb, amp_port: amp, delegate_port: del,
|
|
54
|
+
agent_name: env.fetch("AGENT_NAME", "").strip,
|
|
55
|
+
conversation_id: env.fetch("CONVERSATION_ID", "").strip,
|
|
56
|
+
memory_wired: mem_set, feedback_wired: fb_set,
|
|
57
|
+
long_term_enabled: env.fetch("MEMORY_LONGTERM_ENABLED", "").strip == "true",
|
|
58
|
+
knowledge_enabled: env.fetch("KNOWLEDGE_BASE_ENABLED", "").strip == "true"
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns [value, was_explicitly_set]. The caller needs the difference: an unset port means
|
|
63
|
+
# the capability is not wired, not that it is on the default.
|
|
64
|
+
def self.port(env, name, default)
|
|
65
|
+
raw = env[name]
|
|
66
|
+
return [default, false] if raw.nil? || raw.strip.empty?
|
|
67
|
+
|
|
68
|
+
n = Integer(raw.strip, exception: false)
|
|
69
|
+
unless n && n.between?(1, 65_535)
|
|
70
|
+
raise NotInPodError, "ctxmesh: #{name}=#{raw.inspect} is not a valid port"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
[n, true]
|
|
74
|
+
end
|
|
75
|
+
private_class_method :port
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ctxmesh
|
|
4
|
+
# Base for everything this SDK raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# The launcher environment is absent: this process is not running as a ctxmesh agent.
|
|
8
|
+
# Raised rather than guessing ports, so running an agent on a laptop says so plainly instead
|
|
9
|
+
# of failing later with a connection refused to localhost:2998.
|
|
10
|
+
class NotInPodError < Error; end
|
|
11
|
+
|
|
12
|
+
# The platform did not grant this capability to this agent. A configuration answer, not a
|
|
13
|
+
# failure: the port is absent because nothing is listening.
|
|
14
|
+
class NotWiredError < Error; end
|
|
15
|
+
|
|
16
|
+
# A non-2xx response from the plane.
|
|
17
|
+
class ApiError < Error
|
|
18
|
+
attr_reader :status, :path, :body
|
|
19
|
+
|
|
20
|
+
def initialize(status, path, body)
|
|
21
|
+
@status = status
|
|
22
|
+
@path = path
|
|
23
|
+
@body = body
|
|
24
|
+
super("ctxmesh: #{path} returned #{status}: #{body}")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A 403 — the plane understood the call and refused it. Guardrails, budgets and the delegate
|
|
29
|
+
# fence surface here, and the reason survives: a refusal that says only "403" turns a policy
|
|
30
|
+
# decision into a bare status code.
|
|
31
|
+
class DeniedError < ApiError
|
|
32
|
+
def initialize(path, body)
|
|
33
|
+
super(403, path, body)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/ctxmesh.rb
CHANGED
|
@@ -1,9 +1,265 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "ctxmesh/version"
|
|
8
|
+
require_relative "ctxmesh/errors"
|
|
9
|
+
require_relative "ctxmesh/config"
|
|
10
|
+
|
|
11
|
+
# Ruby SDK for agents running on ctxmesh, the Kubernetes-native control plane for AI agents.
|
|
12
|
+
#
|
|
13
|
+
# An agent runs in a pod beside the platform's sidecars, and this gem is the typed way to reach
|
|
14
|
+
# them over localhost: conversation memory, long-term memory, knowledge bases, skills, feedback,
|
|
15
|
+
# agent-to-agent calls, delegation and handoff.
|
|
4
16
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
17
|
+
# It holds no credentials. Endpoints and identity arrive in the environment the platform
|
|
18
|
+
# injects, so there is no API key to manage and no base URL to configure.
|
|
19
|
+
#
|
|
20
|
+
# Conformance tier: plane-client (ADR 0139). The managed agent loop and model client are
|
|
21
|
+
# authoring-tier and live in the Python and TypeScript SDKs.
|
|
7
22
|
module Ctxmesh
|
|
8
|
-
|
|
23
|
+
# Carries the run capability. Delegation, handoff, per-user session memory, per-user long-term
|
|
24
|
+
# memory and per-user knowledge bases all key on it. Session memory fails SAFE without it --
|
|
25
|
+
# every user silently shares the agent-wide bucket instead of their own -- so omitting it
|
|
26
|
+
# defeats an isolation control with no error to notice.
|
|
27
|
+
CAPABILITY_HEADER = "X-Ctxmesh-Run-Capability"
|
|
28
|
+
|
|
29
|
+
# The entry point.
|
|
30
|
+
class Client
|
|
31
|
+
DEFAULT_TIMEOUT = 15
|
|
32
|
+
# Search may wait on an embedding call through the token-service.
|
|
33
|
+
SEARCH_TIMEOUT = 60
|
|
34
|
+
|
|
35
|
+
attr_reader :config
|
|
36
|
+
|
|
37
|
+
def initialize(config)
|
|
38
|
+
@config = config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Reads the launcher environment. Raises NotInPodError outside a ctxmesh pod.
|
|
42
|
+
def self.from_env(env = ENV) = new(Config.from_env(env))
|
|
43
|
+
|
|
44
|
+
# Builds a client from an explicit config — for tests and offline work.
|
|
45
|
+
def self.from_config(config) = new(config)
|
|
46
|
+
|
|
47
|
+
# ── memory: /memory and /memory/agent ───────────────────────────────────
|
|
48
|
+
|
|
49
|
+
# Returns the conversation so far.
|
|
50
|
+
def memory_get(conversation_id = nil)
|
|
51
|
+
require_memory!
|
|
52
|
+
body = request("GET", "#{@config.memory_base}/memory/#{escape(conv(conversation_id))}")
|
|
53
|
+
body.is_a?(Array) ? body : []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Adds one entry to the conversation.
|
|
57
|
+
def memory_append(role:, content:, conversation_id: nil)
|
|
58
|
+
require_memory!
|
|
59
|
+
request("POST", "#{@config.memory_base}/memory/#{escape(conv(conversation_id))}/append",
|
|
60
|
+
{ role: role, content: content })
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Searches this conversation's memory. capability is optional: without it a per-user agent
|
|
65
|
+
# silently reads the agent-wide bucket rather than the caller's own.
|
|
66
|
+
def memory_search(query, conversation_id: nil, capability: nil)
|
|
67
|
+
require_memory!
|
|
68
|
+
url = "#{@config.memory_base}/memory/#{escape(conv(conversation_id))}/search?q=#{escape(query.to_s)}"
|
|
69
|
+
headers = capability && !capability.empty? ? { CAPABILITY_HEADER => capability } : {}
|
|
70
|
+
body = request("GET", url, nil, DEFAULT_TIMEOUT, headers)
|
|
71
|
+
body.is_a?(Array) ? body : []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Replaces the conversation wholesale.
|
|
75
|
+
def memory_put(entries, conversation_id: nil)
|
|
76
|
+
require_memory!
|
|
77
|
+
request("PUT", "#{@config.memory_base}/memory/#{escape(conv(conversation_id))}", entries)
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Writes a fact to this agent's long-term memory.
|
|
82
|
+
def remember(content, tags = {})
|
|
83
|
+
require_long_term!
|
|
84
|
+
payload = { content: content }
|
|
85
|
+
payload[:tags] = tags unless tags.nil? || tags.empty?
|
|
86
|
+
request("POST", "#{@config.memory_base}/memory/agent/remember", payload)
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Retrieves facts from long-term memory; min_score drops weak matches.
|
|
91
|
+
def search_agent(query, top_k: 5, min_score: 0.0)
|
|
92
|
+
require_long_term!
|
|
93
|
+
body = request("POST", "#{@config.memory_base}/memory/agent/search",
|
|
94
|
+
{ query: query, topK: top_k.positive? ? top_k : 5 })
|
|
95
|
+
results(body).select { |f| f.fetch("score", 0.0) >= min_score }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# ── knowledge: /knowledge/search ────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
# Retrieval over the granted knowledge bases; knowledge_base may be nil for all of them.
|
|
101
|
+
def knowledge_search(query, knowledge_base: nil, top_k: 5)
|
|
102
|
+
unless @config.knowledge_enabled?
|
|
103
|
+
raise NotWiredError, "ctxmesh: knowledge is not enabled (KNOWLEDGE_BASE_ENABLED)"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if knowledge_base.nil? || knowledge_base.strip.empty?
|
|
107
|
+
raise Error, "ctxmesh: knowledgeBase is required (the launcher 400s without it)"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
payload = { query: query, topK: top_k.positive? ? top_k : 5, knowledgeBase: knowledge_base }
|
|
111
|
+
results(request("POST", "#{@config.memory_base}/knowledge/search", payload, SEARCH_TIMEOUT))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# ── skills: /skills and /skills/load ────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
# The skills the platform attached to this agent.
|
|
117
|
+
def skills
|
|
118
|
+
body = request("GET", "#{@config.memory_base}/skills")
|
|
119
|
+
body.is_a?(Hash) ? body.fetch("skills", []) : []
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Fetches a skill's body by name.
|
|
123
|
+
def skill_load(name)
|
|
124
|
+
body = request("POST", "#{@config.memory_base}/skills/load", { name: name })
|
|
125
|
+
# The launcher answers {"body": "..."}. Reading "content" gave "" with NO error,
|
|
126
|
+
# so a skill loaded as nothing and the model carried on without it.
|
|
127
|
+
body.is_a?(Hash) ? body.fetch("body", "") : ""
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# ── feedback: /feedback ─────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
# Records a score against a trace — the signal that drives evals and canary promotion.
|
|
133
|
+
def feedback(trace_id, dimension, score, comment = nil)
|
|
134
|
+
unless @config.feedback_wired?
|
|
135
|
+
raise NotWiredError, "ctxmesh: feedback is not wired (FEEDBACK_PORT unset)"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# name/value, not dimension/score: the wrong keys returned 202 while writing a
|
|
139
|
+
# nameless zero score, silently corrupting the eval signal.
|
|
140
|
+
payload = { traceId: trace_id, name: dimension, value: score }
|
|
141
|
+
payload[:comment] = comment if comment && !comment.empty?
|
|
142
|
+
request("POST", "#{@config.feedback_base}/feedback", payload)
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ── mesh: /amp and /a2a ─────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
# Invokes another agent through AMP.
|
|
149
|
+
def call_agent(target_agent, payload)
|
|
150
|
+
require_target!(target_agent)
|
|
151
|
+
request("POST", "#{@config.amp_base}/amp/#{escape(target_agent)}", payload)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Invokes another agent through the retired /a2a path. Prefer #call_agent.
|
|
155
|
+
#
|
|
156
|
+
# Kept because the launcher still serves it (ADR 0138); an SDK that pretends a served route
|
|
157
|
+
# does not exist is the drift the contract gate exists to prevent.
|
|
158
|
+
def call_agent_legacy(target_agent, payload)
|
|
159
|
+
require_target!(target_agent)
|
|
160
|
+
request("POST", "#{@config.amp_base}/a2a/#{escape(target_agent)}", payload)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ── runs: /delegate and /handoff ────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
# Spawns a sub-run on another agent.
|
|
166
|
+
#
|
|
167
|
+
# step and call_id are the idempotency key the launcher hard-requires -- the supervisor's loop
|
|
168
|
+
# iteration and the model's tool-call id -- so a reclaimed supervisor resolves to the SAME
|
|
169
|
+
# sub-run rather than spawning a second. capability is the run capability.
|
|
170
|
+
#
|
|
171
|
+
# The launcher answers 200 for EVERY outcome and signals success in "ok", so check that: a
|
|
172
|
+
# refusal decoded as a transport success is silent data loss, and "answer" is the entire point.
|
|
173
|
+
def delegate(sub_agent, step:, call_id:, capability:, input: nil)
|
|
174
|
+
raise Error, "ctxmesh: sub-agent is required" if sub_agent.nil? || sub_agent.strip.empty?
|
|
175
|
+
if step.to_s.strip.empty? || call_id.to_s.strip.empty?
|
|
176
|
+
raise Error, "ctxmesh: step and callId are required (the idempotency key)"
|
|
177
|
+
end
|
|
178
|
+
if capability.to_s.strip.empty?
|
|
179
|
+
raise NotWiredError, "ctxmesh: delegation needs the run capability (#{CAPABILITY_HEADER})"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
request("POST", "#{@config.delegate_base}/delegate",
|
|
183
|
+
{ subAgent: sub_agent, input: input, step: step, callId: call_id },
|
|
184
|
+
DEFAULT_TIMEOUT, { CAPABILITY_HEADER => capability }) || {}
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Transfers the conversation to another agent.
|
|
188
|
+
#
|
|
189
|
+
# The launcher treats an ABSENT includeHistory as TRUE, so it is always sent explicitly --
|
|
190
|
+
# defaulting to false handed the receiver nothing. message is its opening note.
|
|
191
|
+
def handoff(target_agent, capability:, message: nil, include_history: true)
|
|
192
|
+
require_target!(target_agent)
|
|
193
|
+
if capability.to_s.strip.empty?
|
|
194
|
+
raise NotWiredError, "ctxmesh: handoff needs the run capability (#{CAPABILITY_HEADER})"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
payload = { targetAgent: target_agent, includeHistory: include_history }
|
|
198
|
+
payload[:message] = message if message && !message.empty?
|
|
199
|
+
request("POST", "#{@config.delegate_base}/handoff", payload,
|
|
200
|
+
DEFAULT_TIMEOUT, { CAPABILITY_HEADER => capability }) || {}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def require_memory!
|
|
206
|
+
return if @config.memory_wired?
|
|
207
|
+
|
|
208
|
+
raise NotWiredError, "ctxmesh: memory is not wired (MEMORY_PORT unset)"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def require_long_term!
|
|
212
|
+
require_memory!
|
|
213
|
+
return if @config.long_term_enabled?
|
|
214
|
+
|
|
215
|
+
raise NotWiredError, "ctxmesh: long-term memory is not enabled (MEMORY_LONGTERM_ENABLED)"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def require_target!(t)
|
|
219
|
+
raise Error, "ctxmesh: target agent is required" if t.nil? || t.strip.empty?
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Explicit id first, else the injected CONVERSATION_ID. Empty is an error rather than a
|
|
223
|
+
# silent write to a shared bucket.
|
|
224
|
+
def conv(id)
|
|
225
|
+
v = id.nil? || id.to_s.strip.empty? ? @config.conversation_id : id.to_s
|
|
226
|
+
raise Error, "ctxmesh: no conversation id (pass one, or set CONVERSATION_ID)" if v.strip.empty?
|
|
227
|
+
if v.include?("/") || v.match?(/\s/)
|
|
228
|
+
raise Error, "ctxmesh: conversation id #{v.inspect} contains a separator or whitespace"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
v
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def results(body) = body.is_a?(Hash) ? body.fetch("results", []) : []
|
|
235
|
+
|
|
236
|
+
def escape(s) = URI.encode_www_form_component(s)
|
|
237
|
+
|
|
238
|
+
def request(method, url, payload = nil, timeout = DEFAULT_TIMEOUT, headers = {})
|
|
239
|
+
uri = URI.parse(url)
|
|
240
|
+
req = Net::HTTP.const_get(method.capitalize).new(uri)
|
|
241
|
+
req["Accept"] = "application/json"
|
|
242
|
+
headers.each { |k, v| req[k] = v if v && !v.to_s.empty? }
|
|
243
|
+
if payload
|
|
244
|
+
req["Content-Type"] = "application/json"
|
|
245
|
+
req.body = JSON.generate(payload)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
resp = Net::HTTP.start(uri.hostname, uri.port, open_timeout: 5, read_timeout: timeout) do |http|
|
|
249
|
+
http.request(req)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
code = resp.code.to_i
|
|
253
|
+
text = (resp.body || "").strip
|
|
254
|
+
raise DeniedError.new(uri.path, text) if code == 403
|
|
255
|
+
raise ApiError.new(code, uri.path, text) unless (200..299).cover?(code)
|
|
256
|
+
return nil if text.empty?
|
|
257
|
+
|
|
258
|
+
JSON.parse(text)
|
|
259
|
+
rescue JSON::ParserError => e
|
|
260
|
+
raise Error, "ctxmesh: decode response from #{uri.path}: #{e.message}"
|
|
261
|
+
rescue SystemCallError, Timeout::Error, IOError => e
|
|
262
|
+
raise Error, "ctxmesh: #{uri.path}: #{e.message}"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
9
265
|
end
|
metadata
CHANGED
|
@@ -1,33 +1,39 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ctxmesh
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.1.0.pre.beta.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ctxmesh
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
|
-
description:
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
description: 'Typed clients for agents running on ctxmesh: conversation memory, long-term
|
|
14
|
+
memory, knowledge bases, skills, feedback and agent-to-agent calls. Reads its endpoints
|
|
15
|
+
from the environment the platform injects, so your code never holds credentials.'
|
|
16
|
+
email:
|
|
16
17
|
executables: []
|
|
17
18
|
extensions: []
|
|
18
19
|
extra_rdoc_files: []
|
|
19
20
|
files:
|
|
20
21
|
- README.md
|
|
21
22
|
- lib/ctxmesh.rb
|
|
22
|
-
|
|
23
|
+
- lib/ctxmesh/config.rb
|
|
24
|
+
- lib/ctxmesh/errors.rb
|
|
25
|
+
- lib/ctxmesh/version.rb
|
|
26
|
+
homepage: https://ctxmesh.github.io
|
|
23
27
|
licenses:
|
|
24
28
|
- Apache-2.0
|
|
25
29
|
metadata:
|
|
30
|
+
homepage_uri: https://ctxmesh.github.io
|
|
26
31
|
source_code_uri: https://github.com/ctxmesh/ctxmesh
|
|
27
32
|
bug_tracker_uri: https://github.com/ctxmesh/ctxmesh/issues
|
|
28
|
-
|
|
33
|
+
documentation_uri: https://ctxmesh.github.io/sdk/
|
|
34
|
+
changelog_uri: https://github.com/ctxmesh/ctxmesh/blob/main/CHANGELOG.md
|
|
29
35
|
rubygems_mfa_required: 'true'
|
|
30
|
-
post_install_message:
|
|
36
|
+
post_install_message:
|
|
31
37
|
rdoc_options: []
|
|
32
38
|
require_paths:
|
|
33
39
|
- lib
|
|
@@ -38,12 +44,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
38
44
|
version: '3.1'
|
|
39
45
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
40
46
|
requirements:
|
|
41
|
-
- - "
|
|
47
|
+
- - ">="
|
|
42
48
|
- !ruby/object:Gem::Version
|
|
43
|
-
version:
|
|
49
|
+
version: '0'
|
|
44
50
|
requirements: []
|
|
45
|
-
rubygems_version: 3.
|
|
46
|
-
signing_key:
|
|
51
|
+
rubygems_version: 3.5.22
|
|
52
|
+
signing_key:
|
|
47
53
|
specification_version: 4
|
|
48
|
-
summary:
|
|
54
|
+
summary: Ruby SDK for ctxmesh agents
|
|
49
55
|
test_files: []
|