Skip to content

Commit e1ef942

Browse files
gcmsgclaude
andcommitted
feat: add version upgrade mechanism with protocol compat checks and heartbeat advisory
- Add ProtocolVersion() to Adapter interface with hard compatibility gate - Fix ClaimRegister() missing sdk_version auto-injection - Inject platform_name/platform_protocol metadata into registration - Expand HeartbeatResponse with VersionAdvisory for SDK update notifications - Add optional Versioned interface for plugin ↔ SDK compat range checks - Log platform adapter compatibility summary on successful connect - Background checkForUpdates via heartbeat after registration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f85a5a1 commit e1ef942

File tree

6 files changed

+208
-3
lines changed

6 files changed

+208
-3
lines changed

agent.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"fmt"
88
"log/slog"
9+
"strconv"
910
"sync"
1011
"time"
1112

@@ -15,6 +16,7 @@ import (
1516
"github.com/peerclaw/peerclaw-agent/filetransfer"
1617
"github.com/peerclaw/peerclaw-agent/peer"
1718
"github.com/peerclaw/peerclaw-agent/platform"
19+
"github.com/peerclaw/peerclaw-agent/sdkversion"
1820
"github.com/peerclaw/peerclaw-agent/security"
1921
pcsignaling "github.com/peerclaw/peerclaw-agent/signaling"
2022
"github.com/peerclaw/peerclaw-agent/transport"
@@ -165,6 +167,7 @@ type Agent struct {
165167
logger *slog.Logger
166168
mu sync.RWMutex
167169
running bool
170+
versionWarned sync.Once
168171
stopNonceCleaner context.CancelFunc
169172
}
170173

@@ -306,6 +309,15 @@ func (a *Agent) Start(ctx context.Context) error {
306309
// Derive agent ID from public key without server registration.
307310
a.agentID = a.keypair.PublicKeyString()
308311
} else {
312+
// Build platform metadata for registration.
313+
var regMeta map[string]string
314+
if a.opts.Platform != nil {
315+
regMeta = map[string]string{
316+
"platform_name": a.opts.Platform.Name(),
317+
"platform_protocol": strconv.Itoa(a.opts.Platform.ProtocolVersion()),
318+
}
319+
}
320+
309321
// Register with the platform.
310322
var regErr error
311323
if a.opts.ClaimToken != "" {
@@ -326,6 +338,7 @@ func (a *Agent) Start(ctx context.Context) error {
326338
Protocols: a.opts.Protocols,
327339
Endpoint: discovery.EndpointReq{URL: "p2p://" + a.keypair.PublicKeyString()},
328340
Signature: sig,
341+
Metadata: regMeta,
329342
})
330343
if err != nil {
331344
regErr = fmt.Errorf("claim register: %w", err)
@@ -340,6 +353,7 @@ func (a *Agent) Start(ctx context.Context) error {
340353
Capabilities: a.Capabilities(),
341354
Endpoint: discovery.EndpointReq{URL: "p2p://" + a.keypair.PublicKeyString()},
342355
Protocols: a.opts.Protocols,
356+
Metadata: regMeta,
343357
})
344358
if err != nil {
345359
regErr = fmt.Errorf("register with platform: %w", err)
@@ -538,6 +552,18 @@ func (a *Agent) Start(ctx context.Context) error {
538552
// Initialize platform adapter integration.
539553
if a.opts.Platform != nil {
540554
pa := a.opts.Platform
555+
556+
// Verify adapter protocol compatibility before connecting.
557+
if err := platform.CheckProtocolVersion(pa); err != nil {
558+
a.mu.Lock()
559+
a.running = false
560+
a.mu.Unlock()
561+
return fmt.Errorf("platform compatibility: %w", err)
562+
}
563+
564+
// Advisory check: warn if SDK is outside adapter's declared compat range.
565+
platform.CheckSDKCompat(pa, a.logger)
566+
541567
pa.SetOutboundHandler(func(sessionKey, text string) {
542568
peerID := platform.ParsePeerFromSessionKey(sessionKey)
543569
if peerID == "" {
@@ -550,6 +576,17 @@ func (a *Agent) Start(ctx context.Context) error {
550576
a.logger.Warn("platform connect failed", "platform", pa.Name(), "error", err)
551577
} else {
552578
a.platformAdapter = pa
579+
580+
// Log platform adapter compatibility summary.
581+
attrs := []any{
582+
"platform", pa.Name(),
583+
"protocol_version", pa.ProtocolVersion(),
584+
"sdk_version", sdkversion.Version,
585+
}
586+
if v, ok := pa.(platform.Versioned); ok {
587+
attrs = append(attrs, "plugin_version", v.PluginVersion())
588+
}
589+
a.logger.Info("platform adapter connected", attrs...)
553590
}
554591

555592
// Forward notifications to platform.
@@ -573,6 +610,11 @@ func (a *Agent) Start(ctx context.Context) error {
573610
}
574611
}
575612

613+
// Check for SDK updates in the background.
614+
if !a.opts.SkipRegistration {
615+
go a.checkForUpdates(ctx)
616+
}
617+
576618
a.logger.Info("agent started", "id", a.agentID, "name", a.opts.Name, "pubkey", a.keypair.PublicKeyString())
577619
return nil
578620
}
@@ -642,6 +684,27 @@ func (a *Agent) Stop(ctx context.Context) error {
642684
return nil
643685
}
644686

687+
// checkForUpdates sends a heartbeat and logs a warning if a newer SDK is available.
688+
func (a *Agent) checkForUpdates(ctx context.Context) {
689+
regClient, ok := a.discovery.(*discovery.RegistryClient)
690+
if !ok {
691+
return
692+
}
693+
resp, err := regClient.Heartbeat(ctx, a.agentID, "online")
694+
if err != nil {
695+
return
696+
}
697+
if resp.VersionAdvisory != nil && resp.VersionAdvisory.SDKUpdateAvailable {
698+
a.versionWarned.Do(func() {
699+
a.logger.Warn("a newer PeerClaw SDK is available",
700+
"current", sdkversion.Version,
701+
"latest", resp.VersionAdvisory.LatestSDK,
702+
"release_url", resp.VersionAdvisory.ReleaseURL,
703+
)
704+
})
705+
}
706+
}
707+
645708
// Send sends an envelope to a peer using P2P (preferred) or signaling relay (fallback).
646709
// The payload is encrypted (if a session key exists), then the envelope is signed
647710
// covering the ciphertext + headers (encrypt-then-sign for pre-authentication).

discovery/registry.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,18 @@ func (c *RegistryClient) Deregister(ctx context.Context, agentID string) error {
120120
return nil
121121
}
122122

123+
// VersionAdvisory is returned in heartbeat responses when a newer SDK version is available.
124+
type VersionAdvisory struct {
125+
SDKUpdateAvailable bool `json:"sdk_update_available,omitempty"`
126+
LatestSDK string `json:"latest_sdk,omitempty"`
127+
ReleaseURL string `json:"release_url,omitempty"`
128+
}
129+
123130
// HeartbeatResponse holds the response from a heartbeat request.
124131
type HeartbeatResponse struct {
125-
NextDeadline time.Time `json:"next_deadline"`
126-
PendingNotifications int `json:"pending_notifications,omitempty"`
132+
NextDeadline time.Time `json:"next_deadline"`
133+
PendingNotifications int `json:"pending_notifications,omitempty"`
134+
VersionAdvisory *VersionAdvisory `json:"version_advisory,omitempty"`
127135
}
128136

129137
// Heartbeat sends a heartbeat to the platform.
@@ -222,6 +230,14 @@ type ClaimRequest struct {
222230
// ClaimRegister registers the agent using a claim token.
223231
// The token itself serves as authentication — no API key or bearer token is needed.
224232
func (c *RegistryClient) ClaimRegister(ctx context.Context, req ClaimRequest) (*agentcard.Card, error) {
233+
// Auto-inject sdk_version if not explicitly set.
234+
if req.Metadata == nil {
235+
req.Metadata = map[string]string{}
236+
}
237+
if _, ok := req.Metadata["sdk_version"]; !ok {
238+
req.Metadata["sdk_version"] = sdkversion.Version
239+
}
240+
225241
body, err := json.Marshal(req)
226242
if err != nil {
227243
return nil, fmt.Errorf("marshal claim request: %w", err)

platform/adapter.go

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,35 @@
22
// agent with AI orchestration platforms (OpenClaw, IronClaw, etc.).
33
package platform
44

5-
import "context"
5+
import (
6+
"context"
7+
"fmt"
8+
"log/slog"
9+
10+
"github.com/peerclaw/peerclaw-agent/sdkversion"
11+
)
612

713
// OutboundHandler is called when the platform produces a final AI response.
814
// sessionKey identifies the conversation; text is the response content.
915
type OutboundHandler func(sessionKey, text string)
1016

17+
// Protocol version constants. The SDK supports adapters whose ProtocolVersion()
18+
// falls within [MinSupportedProtocol, MaxSupportedProtocol].
19+
const (
20+
MinSupportedProtocol = 1
21+
MaxSupportedProtocol = 1
22+
)
23+
1124
// Adapter is the interface that all platform integrations must implement.
1225
// It abstracts the bidirectional bridge between PeerClaw P2P messaging
1326
// and an AI orchestration platform's conversation system.
1427
type Adapter interface {
1528
// Name returns the platform identifier (e.g., "openclaw", "ironclaw", "bridge").
1629
Name() string
1730

31+
// ProtocolVersion returns the bridge protocol version implemented by this adapter.
32+
ProtocolVersion() int
33+
1834
// Connect establishes the connection to the platform.
1935
Connect(ctx context.Context) error
2036

@@ -33,6 +49,101 @@ type Adapter interface {
3349
SetOutboundHandler(handler OutboundHandler)
3450
}
3551

52+
// CheckProtocolVersion returns an error if the adapter's protocol version
53+
// is outside the SDK's supported range.
54+
func CheckProtocolVersion(adapter Adapter) error {
55+
v := adapter.ProtocolVersion()
56+
if v < MinSupportedProtocol || v > MaxSupportedProtocol {
57+
return fmt.Errorf("adapter %q protocol version %d is outside supported range [%d, %d]",
58+
adapter.Name(), v, MinSupportedProtocol, MaxSupportedProtocol)
59+
}
60+
return nil
61+
}
62+
63+
// Versioned is an optional interface that adapters may implement to declare
64+
// their plugin version and SDK compatibility range. If implemented, the SDK
65+
// logs a warning when it falls outside the adapter's declared range.
66+
type Versioned interface {
67+
PluginVersion() string
68+
SDKCompatRange() (minSDK, maxSDK string)
69+
}
70+
71+
// CheckSDKCompat checks if the current SDK version is within the adapter's
72+
// declared compatibility range. Logs a warning if not. This is advisory only.
73+
func CheckSDKCompat(adapter Adapter, logger *slog.Logger) {
74+
v, ok := adapter.(Versioned)
75+
if !ok {
76+
return
77+
}
78+
minSDK, maxSDK := v.SDKCompatRange()
79+
current := sdkversion.Version
80+
if minSDK != "" && compareSemver(current, minSDK) < 0 {
81+
logger.Warn("SDK version below adapter minimum",
82+
"sdk_version", current,
83+
"plugin_version", v.PluginVersion(),
84+
"min_sdk", minSDK,
85+
"adapter", adapter.Name(),
86+
)
87+
}
88+
if maxSDK != "" && compareSemver(current, maxSDK) > 0 {
89+
logger.Warn("SDK version above adapter maximum",
90+
"sdk_version", current,
91+
"plugin_version", v.PluginVersion(),
92+
"max_sdk", maxSDK,
93+
"adapter", adapter.Name(),
94+
)
95+
}
96+
}
97+
98+
// compareSemver compares two semver strings (with optional "v" prefix).
99+
// Returns -1 if a < b, 0 if a == b, 1 if a > b.
100+
func compareSemver(a, b string) int {
101+
aParts := parseSemverParts(a)
102+
bParts := parseSemverParts(b)
103+
if aParts == nil || bParts == nil {
104+
return 0
105+
}
106+
for i := 0; i < 3; i++ {
107+
if aParts[i] < bParts[i] {
108+
return -1
109+
}
110+
if aParts[i] > bParts[i] {
111+
return 1
112+
}
113+
}
114+
return 0
115+
}
116+
117+
func parseSemverParts(v string) []int {
118+
if len(v) > 0 && v[0] == 'v' {
119+
v = v[1:]
120+
}
121+
var parts [3]int
122+
idx := 0
123+
for i, c := range v {
124+
if c == '.' {
125+
idx++
126+
if idx >= 3 {
127+
return nil
128+
}
129+
continue
130+
}
131+
if c == '-' {
132+
// pre-release suffix — stop here
133+
break
134+
}
135+
if c < '0' || c > '9' {
136+
return nil
137+
}
138+
parts[idx] = parts[idx]*10 + int(c-'0')
139+
_ = i
140+
}
141+
if idx != 2 {
142+
return nil
143+
}
144+
return parts[:]
145+
}
146+
36147
// SessionKeyForPeer returns the platform session key for a DM with the given peer.
37148
func SessionKeyForPeer(peerAgentID string) string {
38149
return "peerclaw:dm:" + peerAgentID

platform/bridge/adapter.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ func NewAdapter(cfg Config, logger *slog.Logger) *Adapter {
4242
// Name returns "bridge".
4343
func (a *Adapter) Name() string { return "bridge" }
4444

45+
// ProtocolVersion returns the bridge protocol version.
46+
func (a *Adapter) ProtocolVersion() int { return 1 }
47+
4548
// SetOutboundHandler registers a handler called when the platform produces a final AI response.
4649
func (a *Adapter) SetOutboundHandler(handler platform.OutboundHandler) {
4750
a.mu.Lock()

platform/ironclaw/adapter.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ func NewAdapter(cfg Config, agentID string, logger *slog.Logger) *Adapter {
4848
// Name returns "ironclaw".
4949
func (a *Adapter) Name() string { return "ironclaw" }
5050

51+
// ProtocolVersion returns the bridge protocol version.
52+
func (a *Adapter) ProtocolVersion() int { return 1 }
53+
5154
// SetOutboundHandler registers a handler called when IronClaw produces a final AI response.
5255
func (a *Adapter) SetOutboundHandler(handler platform.OutboundHandler) {
5356
a.mu.Lock()

platform/openclaw/adapter.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ func NewAdapter(cfg Config, agentID, agentName, version string, logger *slog.Log
5151
// Name returns "openclaw".
5252
func (a *Adapter) Name() string { return "openclaw" }
5353

54+
// ProtocolVersion returns the bridge protocol version.
55+
func (a *Adapter) ProtocolVersion() int { return 1 }
56+
57+
// PluginVersion returns the adapter's version string.
58+
func (a *Adapter) PluginVersion() string { return a.version }
59+
60+
// SDKCompatRange returns the minimum SDK version this adapter is tested against.
61+
func (a *Adapter) SDKCompatRange() (string, string) { return "0.8.0", "" }
62+
5463
// SetOutboundHandler registers a handler called when OpenClaw produces a final AI response.
5564
func (a *Adapter) SetOutboundHandler(handler platform.OutboundHandler) {
5665
a.mu.Lock()

0 commit comments

Comments
 (0)