Skip to content

Commit c44b572

Browse files
Add variant labels and capability advertisement (Tasks #33, #34) (#29)
## Summary Extends the local-coord peer model to support free-form model/variant identifiers (Task #33) and capability advertisement for cold-start routing (Task #34). Adds two new client kinds (OpenAI, Mistral) and introduces FFI functions to set and read variant labels, capability classes, tiers, and prover strengths. Closes #33, #34 ## Changes - **Task #33 — Variant Labels**: Added `variant` field to `Peer` struct (max 32 bytes, alphanumeric + `.`/`-`/`_`). New FFI exports: - `coord_set_variant()` — set/clear variant after registration - `coord_read_peer_variant()` — read another peer's variant (broadcast-visible) - **Task #34 — Capability Advertisement**: Added `class_csv`, `tier`, and `prover_strengths` fields to `Peer` struct. New FFI exports: - `coord_set_capabilities()` — set class/tier/prover_strengths in one call - `coord_read_peer_class()`, `coord_read_peer_tier()`, `coord_read_peer_provers()` — read capabilities - `coord_get_peer_capabilities()` — adapter endpoint for querying another peer's full capability profile - **Client Kind Extensions**: Added `openai` (4) and `mistral` (5) to `ClientKind` enum; both default to apprentice role. - **Adapter Support**: - `kindFromString()` and `arrayToCsv()` helpers for JSON→FFI marshalling - Variant and capabilities accepted at registration time - `coord_set_variant` and `coord_set_capabilities` tools for post-registration updates - `coord_get_peer_capabilities` tool for querying peer metadata - **Durability**: Added event types `peer_variant_set` (15) and `peer_capabilities_set` (16) with encode/decode functions for replay. - **Validation**: Variant and capability fields validated for length and character set (CSV-safe); tier clamped to 0..5; oversized payloads rejected with -2 return code. - **Tests**: Added unit tests for variant setting/reading and capability advertisement (set/read round-trips, validation, slot reuse cleanup). - **Version Bump**: Cartridge version 0.2.0 → 0.7.0; schema version 0.6.0 → 0.7.0. ## RSR Quality Checklist ### Required - [x] Tests pass (added `test "set and read peer variant"` and `test "set and read peer capabilities"`) - [x] Code is formatted (Zig style consistent with existing codebase) - [x] Linter is clean (no new warnings) - [x] No banned language patterns - [x] No `unsafe` blocks - [x] No banned functions - [x] SPDX headers present (existing files, no new source files added) - [x] No secrets or credentials ### As Applicable - [x] ABI/FFI changes validated (new FFI exports in `local_coord_ffi.zig`, adapter marshalling in `local_coord_adapter.zig`, durability codecs in `coord_durability.zig`) - [x] Schema updated (`cartridge.json` and `cartridge.ncl` reflect new tools and parameters) - [x] Idris ABI stub updated (`SafeLocalCoord.idr` extended with `Openai` and `Mistral` constructors) ## Testing Unit tests cover: - Variant validation (alphanumeric + `.`/`-`/`_`, max 32 bytes; rejects spaces) - Capability round-trips (class/tier/prover_strengths set and read) - Tier range validation (0..5; rejects >5) - Character validation (CSV-safe alphabet; rejects quotes) - Slot reuse cleanup (variant and capabilities cleared on peer deregistration) Existing tests for peer registration, role derivation, and token authentication remain passing https://claude.ai/code/session_01DobLJY3jgoso4M3z7xcZi8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent ead4871 commit c44b572

6 files changed

Lines changed: 803 additions & 31 deletions

File tree

cartridges/local-coord-mcp/abi/LocalCoord/SafeLocalCoord.idr

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,20 +149,25 @@ data TokenMatch : SessionToken -> SessionToken -> Type where
149149
-- ═══════════════════════════════════════════════════════════════════════════
150150

151151
||| Known client type prefixes for the hybrid identity model.
152-
||| e.g. "claude", "gemini", "copilot", "custom"
152+
||| e.g. "claude", "gemini", "copilot", "custom", "openai", "mistral"
153+
||| (Task #33: extended 2026-04 to cover OpenAI and Mistral families.)
153154
public export
154155
data ClientKind : Type where
155156
Claude : ClientKind
156157
Gemini : ClientKind
157158
Copilot : ClientKind
158159
Custom : ClientKind
160+
Openai : ClientKind
161+
Mistral : ClientKind
159162

160163
public export
161164
Show ClientKind where
162165
show Claude = "claude"
163166
show Gemini = "gemini"
164167
show Copilot = "copilot"
165168
show Custom = "custom"
169+
show Openai = "openai"
170+
show Mistral = "mistral"
166171

167172
||| A peer identity: human-readable prefix + 4-character hex suffix.
168173
||| e.g. "claude-7f3a", "gemini-b2c1"
@@ -231,12 +236,16 @@ clientKindToInt Claude = 0
231236
clientKindToInt Gemini = 1
232237
clientKindToInt Copilot = 2
233238
clientKindToInt Custom = 3
239+
clientKindToInt Openai = 4
240+
clientKindToInt Mistral = 5
234241

235242
public export
236243
intToClientKind : Int -> ClientKind
237244
intToClientKind 0 = Claude
238245
intToClientKind 1 = Gemini
239246
intToClientKind 2 = Copilot
247+
intToClientKind 4 = Openai
248+
intToClientKind 5 = Mistral
240249
intToClientKind _ = Custom
241250

242251
||| FFI: Get the bind port.

cartridges/local-coord-mcp/adapter/local_coord_adapter.zig

Lines changed: 222 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,42 @@ fn kindName(kind: i32) []const u8 {
2424
0 => "claude",
2525
1 => "gemini",
2626
2 => "copilot",
27+
4 => "openai",
28+
5 => "mistral",
2729
else => "custom",
2830
};
2931
}
3032

33+
/// Map the client_kind JSON string to the FFI integer. Unknown names
34+
/// fall through to `custom` (3) — matches the enum's catch-all.
35+
fn kindFromString(s: []const u8) i32 {
36+
if (std.mem.eql(u8, s, "claude")) return 0;
37+
if (std.mem.eql(u8, s, "gemini")) return 1;
38+
if (std.mem.eql(u8, s, "copilot")) return 2;
39+
if (std.mem.eql(u8, s, "openai")) return 4;
40+
if (std.mem.eql(u8, s, "mistral")) return 5;
41+
return 3; // custom
42+
}
43+
44+
/// Fold a JSON array of strings into a CSV, respecting `cap`. Items that
45+
/// are not strings are skipped. Used for declared_affinities, class, and
46+
/// prover_strengths at register time.
47+
fn arrayToCsv(items: []const std.json.Value, buf: []u8) usize {
48+
var len: usize = 0;
49+
for (items) |item| {
50+
if (item != .string) continue;
51+
const s = item.string;
52+
if (len > 0 and len < buf.len) {
53+
buf[len] = ',';
54+
len += 1;
55+
}
56+
const to_copy: usize = @min(s.len, buf.len - len);
57+
if (to_copy > 0) @memcpy(buf[len .. len + to_copy], s[0..to_copy]);
58+
len += to_copy;
59+
}
60+
return len;
61+
}
62+
3163
fn stateName(state: i32) []const u8 {
3264
return switch (state) {
3365
0 => "registering",
@@ -72,10 +104,7 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
72104

73105
const kind_val = parsed.value.object.get("client_kind") orelse return .{ .status = 400, .body = errJson(resp, "missing client_kind") };
74106
const kind_str = kind_val.string;
75-
var kind: i32 = 3;
76-
if (std.mem.eql(u8, kind_str, "claude")) kind = 0;
77-
if (std.mem.eql(u8, kind_str, "gemini")) kind = 1;
78-
if (std.mem.eql(u8, kind_str, "copilot")) kind = 2;
107+
const kind: i32 = kindFromString(kind_str);
79108

80109
// Optional context for per-window disambiguation.
81110
const ctx_str: []const u8 = blk: {
@@ -117,24 +146,65 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
117146
if (parsed.value.object.get("declared_affinities")) |decl_val| {
118147
if (decl_val == .array) {
119148
var csv_buf: [256]u8 = undefined;
120-
var csv_len: usize = 0;
121-
for (decl_val.array.items) |item| {
122-
if (item != .string) continue;
123-
const s = item.string;
124-
if (csv_len > 0 and csv_len < csv_buf.len) {
125-
csv_buf[csv_len] = ',';
126-
csv_len += 1;
127-
}
128-
const to_copy: usize = @min(s.len, csv_buf.len - csv_len);
129-
if (to_copy > 0) @memcpy(csv_buf[csv_len .. csv_len + to_copy], s[0..to_copy]);
130-
csv_len += to_copy;
131-
}
149+
const csv_len = arrayToCsv(decl_val.array.items, &csv_buf);
132150
if (csv_len > 0) {
133151
_ = ffi.coord_set_declared_affinities(&token, 16, &csv_buf, @intCast(csv_len));
134152
}
135153
}
136154
}
137155

156+
// Task #33 — optional free-form variant label (e.g. "opus-4.7").
157+
// Invalid chars / oversize → rollback so the caller sees a clear 400.
158+
if (parsed.value.object.get("variant")) |var_val| {
159+
if (var_val == .string) {
160+
const vs = var_val.string;
161+
if (vs.len > 0) {
162+
const rc = ffi.coord_set_variant(&token, 16, vs.ptr, @intCast(vs.len));
163+
if (rc < 0) {
164+
_ = ffi.coord_deregister(&token, 16);
165+
return .{ .status = 400, .body = errJson(resp, "invalid variant (alphanum / . / - / _ only, max 32 bytes)") };
166+
}
167+
}
168+
}
169+
}
170+
171+
// Task #34 — optional capability advertisement block:
172+
// "capabilities": { "class": [...], "tier": 1..5, "prover_strengths": [...] }
173+
// All three keys are individually optional. Oversized / bad tier → 400 + rollback.
174+
if (parsed.value.object.get("capabilities")) |caps_val| {
175+
if (caps_val == .object) {
176+
var class_buf: [128]u8 = undefined;
177+
var class_len: usize = 0;
178+
if (caps_val.object.get("class")) |cv| {
179+
if (cv == .array) class_len = arrayToCsv(cv.array.items, &class_buf);
180+
}
181+
182+
var tier: i32 = 0;
183+
if (caps_val.object.get("tier")) |tv| {
184+
if (tv == .integer) tier = @intCast(tv.integer);
185+
}
186+
187+
var pro_buf: [256]u8 = undefined;
188+
var pro_len: usize = 0;
189+
if (caps_val.object.get("prover_strengths")) |pv| {
190+
if (pv == .array) pro_len = arrayToCsv(pv.array.items, &pro_buf);
191+
}
192+
193+
if (class_len != 0 or tier != 0 or pro_len != 0) {
194+
const rc = ffi.coord_set_capabilities(
195+
&token, 16,
196+
&class_buf, @intCast(class_len),
197+
tier,
198+
&pro_buf, @intCast(pro_len),
199+
);
200+
if (rc < 0) {
201+
_ = ffi.coord_deregister(&token, 16);
202+
return .{ .status = 400, .body = errJson(resp, "invalid capabilities (tier 0..5, class ≤128B, prover_strengths ≤256B)") };
203+
}
204+
}
205+
}
206+
}
207+
138208
var token_hex: [32]u8 = undefined;
139209
const hex_chars = "0123456789abcdef";
140210
for (token, 0..) |b, i| {
@@ -190,12 +260,16 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
190260
const ctx_len = ffi.coord_read_peer_context(i, &ctx_buf, @intCast(ctx_buf.len));
191261
const ctx_slice: []const u8 = if (ctx_len > 0) ctx_buf[0..@intCast(ctx_len)] else "";
192262

263+
var variant_buf: [32]u8 = undefined;
264+
const v_len = ffi.coord_read_peer_variant(i, &variant_buf, @intCast(variant_buf.len));
265+
const variant_slice: []const u8 = if (v_len > 0) variant_buf[0..@intCast(v_len)] else "";
266+
193267
var peer_id_buf: [96]u8 = undefined;
194268
const peer_id = renderPeerId(&peer_id_buf, kindName(kind_val), suffix, ctx_slice) catch return .{ .status = 500, .body = errJson(resp, "peer_id render overflow") };
195269

196270
if (written_idx > 0) w.writeAll(",") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
197-
std.fmt.format(w, "{{\"peer_id\":\"{s}\",\"kind\":\"{s}\",\"state\":\"{s}\",\"context\":\"{s}\",\"status\":\"{s}\"}}", .{
198-
peer_id, kindName(kind_val), stateName(state), ctx_slice, status_slice,
271+
std.fmt.format(w, "{{\"peer_id\":\"{s}\",\"kind\":\"{s}\",\"state\":\"{s}\",\"context\":\"{s}\",\"variant\":\"{s}\",\"status\":\"{s}\"}}", .{
272+
peer_id, kindName(kind_val), stateName(state), ctx_slice, variant_slice, status_slice,
199273
}) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
200274
written_idx += 1;
201275
}
@@ -631,9 +705,139 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
631705
return .{ .status = 500, .body = errJson(resp, "reject failed") };
632706
}
633707

708+
if (std.mem.eql(u8, tool, "coord_set_variant")) {
709+
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") };
710+
defer parsed.deinit();
711+
const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") };
712+
const variant_val = parsed.value.object.get("variant") orelse return .{ .status = 400, .body = errJson(resp, "missing variant") };
713+
if (variant_val != .string) return .{ .status = 400, .body = errJson(resp, "variant must be a string") };
714+
715+
var token: [16]u8 = undefined;
716+
if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") };
717+
718+
const vs = variant_val.string;
719+
const rc = ffi.coord_set_variant(&token, 16, vs.ptr, @intCast(vs.len));
720+
if (rc == 0) return .{ .status = 200, .body = okJson(resp, "set") };
721+
if (rc == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") };
722+
if (rc == -2) return .{ .status = 400, .body = errJson(resp, "invalid variant (alphanum / . / - / _ only, max 32 bytes)") };
723+
return .{ .status = 500, .body = errJson(resp, "set_variant failed") };
724+
}
725+
726+
if (std.mem.eql(u8, tool, "coord_set_capabilities")) {
727+
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") };
728+
defer parsed.deinit();
729+
const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") };
730+
var token: [16]u8 = undefined;
731+
if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") };
732+
733+
var class_buf: [128]u8 = undefined;
734+
var class_len: usize = 0;
735+
if (parsed.value.object.get("class")) |cv| {
736+
if (cv == .array) class_len = arrayToCsv(cv.array.items, &class_buf);
737+
}
738+
739+
var tier: i32 = 0;
740+
if (parsed.value.object.get("tier")) |tv| {
741+
if (tv == .integer) tier = @intCast(tv.integer);
742+
}
743+
744+
var pro_buf: [256]u8 = undefined;
745+
var pro_len: usize = 0;
746+
if (parsed.value.object.get("prover_strengths")) |pv| {
747+
if (pv == .array) pro_len = arrayToCsv(pv.array.items, &pro_buf);
748+
}
749+
750+
const rc = ffi.coord_set_capabilities(
751+
&token, 16,
752+
&class_buf, @intCast(class_len),
753+
tier,
754+
&pro_buf, @intCast(pro_len),
755+
);
756+
if (rc == 0) return .{ .status = 200, .body = okJson(resp, "set") };
757+
if (rc == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") };
758+
if (rc == -2) return .{ .status = 400, .body = errJson(resp, "invalid capabilities (tier 0..5, class ≤128B, prover_strengths ≤256B)") };
759+
return .{ .status = 500, .body = errJson(resp, "set_capabilities failed") };
760+
}
761+
762+
if (std.mem.eql(u8, tool, "coord_get_peer_capabilities")) {
763+
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") };
764+
defer parsed.deinit();
765+
const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") };
766+
const target_val = parsed.value.object.get("peer_id") orelse return .{ .status = 400, .body = errJson(resp, "missing peer_id") };
767+
768+
var token: [16]u8 = undefined;
769+
if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") };
770+
771+
// Validate caller token by calling any authenticated read; the cheapest
772+
// is coord_list_peers with a zero-cap buffer (returns -1 on bad token).
773+
var probe: [1]u8 = undefined;
774+
if (ffi.coord_list_peers(&token, 16, &probe, 0) < 0) {
775+
return .{ .status = 401, .body = errJson(resp, "unauthenticated") };
776+
}
777+
778+
const target_str = target_val.string;
779+
const suffix = extractSuffix(target_str) orelse return .{ .status = 400, .body = errJson(resp, "invalid peer_id format — expected <kind>-<4hex>[@<context>]") };
780+
const peer_idx = ffi.coord_find_peer_by_suffix(suffix.ptr);
781+
if (peer_idx < 0) return .{ .status = 404, .body = errJson(resp, "peer not found") };
782+
783+
// Read each component separately; renderJSON arrays from CSVs.
784+
var class_buf: [128]u8 = undefined;
785+
const class_n = ffi.coord_read_peer_class(peer_idx, &class_buf, @intCast(class_buf.len));
786+
const class_slice: []const u8 = if (class_n > 0) class_buf[0..@intCast(class_n)] else "";
787+
788+
const tier_val = ffi.coord_read_peer_tier(peer_idx);
789+
790+
var pro_buf: [256]u8 = undefined;
791+
const pro_n = ffi.coord_read_peer_provers(peer_idx, &pro_buf, @intCast(pro_buf.len));
792+
const pro_slice: []const u8 = if (pro_n > 0) pro_buf[0..@intCast(pro_n)] else "";
793+
794+
var variant_buf: [32]u8 = undefined;
795+
const v_n = ffi.coord_read_peer_variant(peer_idx, &variant_buf, @intCast(variant_buf.len));
796+
const variant_slice: []const u8 = if (v_n > 0) variant_buf[0..@intCast(v_n)] else "";
797+
798+
const kind_val = ffi.coord_read_peer_kind(peer_idx);
799+
800+
// Reconstruct the canonical peer_id from server-side state rather than
801+
// echoing user input. Avoids reflecting any unvalidated chars into JSON.
802+
var ctx_buf2: [32]u8 = undefined;
803+
const ctx_n = ffi.coord_read_peer_context(peer_idx, &ctx_buf2, @intCast(ctx_buf2.len));
804+
const ctx_slice2: []const u8 = if (ctx_n > 0) ctx_buf2[0..@intCast(ctx_n)] else "";
805+
var canon_id_buf: [96]u8 = undefined;
806+
const canon_id = renderPeerId(&canon_id_buf, kindName(kind_val), suffix, ctx_slice2) catch return .{ .status = 500, .body = errJson(resp, "peer_id render overflow") };
807+
808+
var stream = std.io.fixedBufferStream(resp);
809+
const w = stream.writer();
810+
std.fmt.format(w,
811+
"{{\"success\":true,\"peer_id\":\"{s}\",\"kind\":\"{s}\",\"variant\":\"{s}\",\"tier\":{d},\"class\":[",
812+
.{ canon_id, kindName(kind_val), variant_slice, tier_val },
813+
) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
814+
writeCsvAsJsonStrings(w, class_slice) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
815+
w.writeAll("],\"prover_strengths\":[") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
816+
writeCsvAsJsonStrings(w, pro_slice) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
817+
w.writeAll("]}") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
818+
return .{ .status = 200, .body = resp[0..stream.pos] };
819+
}
820+
634821
return .{ .status = 404, .body = errJson(resp, "not implemented") };
635822
}
636823

824+
/// Emit a CSV (comma-separated, no quoting) as a JSON array of strings
825+
/// into the writer. Empty CSV → empty output. Caller supplies surrounding
826+
/// `[` and `]`.
827+
fn writeCsvAsJsonStrings(w: anytype, csv: []const u8) !void {
828+
if (csv.len == 0) return;
829+
var it = std.mem.splitScalar(u8, csv, ',');
830+
var first = true;
831+
while (it.next()) |part| {
832+
if (part.len == 0) continue;
833+
if (!first) try w.writeAll(",");
834+
first = false;
835+
try w.writeAll("\"");
836+
try w.writeAll(part);
837+
try w.writeAll("\"");
838+
}
839+
}
840+
637841
fn handleConnection(stream: std.net.Stream, allocator: std.mem.Allocator) void {
638842
defer stream.close();
639843
var buf: [8192]u8 = undefined;

0 commit comments

Comments
 (0)