Skip to content

Commit a5f9435

Browse files
hyperpolymathclaude
andcommitted
feat(adr-0006): cartridge_shim helper + ml-mcp invoke migration
Second cartridge on the 5-symbol ABI after aerie-mcp reference. - ffi/zig/src/cartridge_shim.zig — RC_* constants, invokeArgsNull, toolIs, writeResult. Cuts per-cartridge invoke to ~20 LOC vs the 60-120 the ADR anticipated. Tests: 6/6. - cartridges/ml-mcp — boj_cartridge_invoke dispatches 4 cartridge.json tools (ml_authenticate, ml_inference, ml_list_models, ml_get_model_info) to Grade-D-Alpha stub JSON bodies matching each tool's shape. Buffer-too-small + unknown-tool paths covered. Tests: 18/18 (12 pre-existing + 6 invoke). - Local LLM integration surface (ollama/HF/OpenAI/Anthropic providers declared in cartridge.json) now dispatchable via the standard ABI. Concrete provider backends remain a follow-up. Elixir router boj_cartridge_invoke CLI verb unblocked once a third cartridge lands on this shim. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fcd8d20 commit a5f9435

4 files changed

Lines changed: 259 additions & 5 deletions

File tree

cartridges/ml-mcp/ffi/build.zig

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,19 @@ pub fn build(b: *std.Build) void {
99
const target = b.standardTargetOptions(.{});
1010
const optimize = b.standardOptimizeOption(.{});
1111

12+
// Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk).
13+
const shim_mod = b.addModule("cartridge_shim", .{
14+
.root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"),
15+
.target = target,
16+
.optimize = optimize,
17+
});
18+
1219
const ml_mod = b.addModule("ml_ffi", .{
1320
.root_source_file = b.path("ml_ffi.zig"),
1421
.target = target,
1522
.optimize = optimize,
1623
});
24+
ml_mod.addImport("cartridge_shim", shim_mod);
1725

1826
// ── Tests ────────────────────────────────────────────────────────
1927
const ml_tests = b.addTest(.{
@@ -26,13 +34,16 @@ pub fn build(b: *std.Build) void {
2634
test_step.dependOn(&run_tests.step);
2735

2836
// ── Shared library ──────────────────────────────────────────────
37+
const lib_mod = b.createModule(.{
38+
.root_source_file = b.path("ml_ffi.zig"),
39+
.target = target,
40+
.optimize = optimize,
41+
});
42+
lib_mod.addImport("cartridge_shim", shim_mod);
43+
2944
const lib = b.addLibrary(.{
3045
.name = "ml_mcp",
31-
.root_module = b.createModule(.{
32-
.root_source_file = b.path("ml_ffi.zig"),
33-
.target = target,
34-
.optimize = optimize,
35-
}),
46+
.root_module = lib_mod,
3647
.linkage = .dynamic,
3748
});
3849
b.installArtifact(lib);

cartridges/ml-mcp/ffi/ml_ffi.zig

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,40 @@ pub export fn boj_cartridge_version() [*:0]const u8 {
186186
return "0.1.0";
187187
}
188188

189+
// ═══════════════════════════════════════════════════════════════════════
190+
// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol)
191+
// ═══════════════════════════════════════════════════════════════════════
192+
193+
const shim = @import("cartridge_shim");
194+
195+
/// Dispatch the 4 cartridge.json MCP tools. Grade D Alpha — each arm
196+
/// returns a stub JSON body that reflects the tool's intended shape.
197+
/// `json_args` is ignored here; providers that need args (e.g. the
198+
/// `provider` discriminator in `ml_authenticate`) parse them in a
199+
/// follow-up migration once dispatch is wired end-to-end.
200+
export fn boj_cartridge_invoke(
201+
tool_name: [*c]const u8,
202+
json_args: [*c]const u8,
203+
out_buf: [*c]u8,
204+
in_out_len: [*c]usize,
205+
) callconv(.c) i32 {
206+
_ = json_args;
207+
if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS;
208+
209+
const body: []const u8 = if (shim.toolIs(tool_name, "ml_authenticate"))
210+
"{\"result\":{\"session_id\":0,\"status\":\"stub\",\"note\":\"Grade D Alpha\"}}"
211+
else if (shim.toolIs(tool_name, "ml_inference"))
212+
"{\"result\":{\"output\":\"\",\"status\":\"stub\",\"note\":\"Grade D Alpha\"}}"
213+
else if (shim.toolIs(tool_name, "ml_list_models"))
214+
"{\"result\":{\"models\":[],\"status\":\"stub\",\"note\":\"Grade D Alpha\"}}"
215+
else if (shim.toolIs(tool_name, "ml_get_model_info"))
216+
"{\"result\":{\"metadata\":{},\"status\":\"stub\",\"note\":\"Grade D Alpha\"}}"
217+
else
218+
return shim.RC_UNKNOWN_TOOL;
219+
220+
return shim.writeResult(out_buf, in_out_len, body);
221+
}
222+
189223
// ═══════════════════════════════════════════════════════════════════════
190224
// Hugging Face Provider (provider code 1)
191225
// Grade D Alpha — stub implementations
@@ -447,6 +481,57 @@ test "huggingface datasets" {
447481
_ = ml_logout(slot);
448482
}
449483

484+
// ═══════════════════════════════════════════════════════════════════════
485+
// ADR-0006 invoke dispatch tests
486+
// ═══════════════════════════════════════════════════════════════════════
487+
488+
test "invoke: ml_authenticate returns session_id stub" {
489+
var buf: [256]u8 = undefined;
490+
var len: usize = buf.len;
491+
const rc = boj_cartridge_invoke("ml_authenticate", "{}", &buf, &len);
492+
try std.testing.expectEqual(@as(i32, 0), rc);
493+
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "session_id") != null);
494+
}
495+
496+
test "invoke: ml_inference returns output stub" {
497+
var buf: [256]u8 = undefined;
498+
var len: usize = buf.len;
499+
const rc = boj_cartridge_invoke("ml_inference", "{}", &buf, &len);
500+
try std.testing.expectEqual(@as(i32, 0), rc);
501+
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "output") != null);
502+
}
503+
504+
test "invoke: ml_list_models returns models array" {
505+
var buf: [256]u8 = undefined;
506+
var len: usize = buf.len;
507+
const rc = boj_cartridge_invoke("ml_list_models", "{}", &buf, &len);
508+
try std.testing.expectEqual(@as(i32, 0), rc);
509+
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "models") != null);
510+
}
511+
512+
test "invoke: ml_get_model_info returns metadata" {
513+
var buf: [256]u8 = undefined;
514+
var len: usize = buf.len;
515+
const rc = boj_cartridge_invoke("ml_get_model_info", "{}", &buf, &len);
516+
try std.testing.expectEqual(@as(i32, 0), rc);
517+
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "metadata") != null);
518+
}
519+
520+
test "invoke: unknown tool returns -1" {
521+
var buf: [256]u8 = undefined;
522+
var len: usize = buf.len;
523+
const rc = boj_cartridge_invoke("not_a_tool", "{}", &buf, &len);
524+
try std.testing.expectEqual(@as(i32, -1), rc);
525+
}
526+
527+
test "invoke: buffer too small returns -3 and sets required length" {
528+
var buf: [4]u8 = undefined;
529+
var len: usize = buf.len;
530+
const rc = boj_cartridge_invoke("ml_authenticate", "{}", &buf, &len);
531+
try std.testing.expectEqual(@as(i32, -3), rc);
532+
try std.testing.expect(len > 4);
533+
}
534+
450535
test "huggingface wrong-provider rejection" {
451536
ml_reset();
452537
// Authenticate as custom, not hugging_face

ffi/zig/build.zig

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,21 @@ pub fn build(b: *std.Build) void {
282282
const seams_step = b.step("seams", "Run integration seam checks (panic-attack style)");
283283
seams_step.dependOn(&run_seams_tests.step);
284284

285+
// --- Cartridge shim tests (ADR-0006 helpers) ---
286+
const shim_mod = b.addModule("boj_cartridge_shim", .{
287+
.root_source_file = b.path("src/cartridge_shim.zig"),
288+
.target = target,
289+
.optimize = optimize,
290+
});
291+
292+
const shim_tests = b.addTest(.{
293+
.root_module = shim_mod,
294+
});
295+
const run_shim_tests = b.addRunArtifact(shim_tests);
296+
297+
const shim_step = b.step("shim", "Run cartridge_shim tests (ADR-0006 invoke helpers)");
298+
shim_step.dependOn(&run_shim_tests.step);
299+
285300
// --- End-to-end order-ticket tests ---
286301
const e2e_mod = b.addModule("boj_e2e_order", .{
287302
.root_source_file = b.path("src/e2e_order.zig"),
@@ -312,4 +327,5 @@ pub fn build(b: *std.Build) void {
312327
test_step.dependOn(&run_community_tests.step);
313328
test_step.dependOn(&run_sdp_tests.step);
314329
test_step.dependOn(&run_seams_tests.step);
330+
test_step.dependOn(&run_shim_tests.step);
315331
}

ffi/zig/src/cartridge_shim.zig

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// cartridge_shim.zig — Shared helpers for the ADR-0006 five-symbol
5+
// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`).
6+
//
7+
// The shim centralises the seven-code return convention, NUL-argument
8+
// guards, tool-name comparison, and the buffer-too-small path so each
9+
// cartridge's `boj_cartridge_invoke` can stay short — typically a tool
10+
// table plus `shim.writeResult(...)`.
11+
//
12+
// Cartridges import this file by relative path (no build-graph change
13+
// needed). Example:
14+
//
15+
// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig");
16+
//
17+
// export fn boj_cartridge_invoke(
18+
// tool_name: [*c]const u8,
19+
// json_args: [*c]const u8,
20+
// out_buf: [*c]u8,
21+
// in_out_len: [*c]usize,
22+
// ) callconv(.c) i32 {
23+
// _ = json_args;
24+
// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS;
25+
// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}"
26+
// else return shim.RC_UNKNOWN_TOOL;
27+
// return shim.writeResult(out_buf, in_out_len, body);
28+
// }
29+
30+
const std = @import("std");
31+
32+
// ── Return codes (ADR-0006 §Return codes) ────────────────────────────
33+
//
34+
// Frozen by ADR-0006. New failure modes compose these via the error
35+
// JSON body — the integer surface does not grow without a follow-up ADR.
36+
37+
pub const RC_SUCCESS: i32 = 0;
38+
pub const RC_UNKNOWN_TOOL: i32 = -1;
39+
pub const RC_BAD_ARGS: i32 = -2;
40+
pub const RC_BUFFER_TOO_SMALL: i32 = -3;
41+
pub const RC_RUNTIME_ERROR: i32 = -4;
42+
pub const RC_PANIC: i32 = -5;
43+
pub const RC_AUTH_DENIED: i32 = -6;
44+
45+
// ── Invoke-path helpers ──────────────────────────────────────────────
46+
47+
/// True if any of the three mandatory `boj_cartridge_invoke` output-path
48+
/// pointers is null. Use at the top of every invoke to short-circuit to
49+
/// `RC_BAD_ARGS`.
50+
pub fn invokeArgsNull(
51+
tool_name: [*c]const u8,
52+
out_buf: [*c]u8,
53+
in_out_len: [*c]usize,
54+
) bool {
55+
return tool_name == null or out_buf == null or in_out_len == null;
56+
}
57+
58+
/// Compare a C-NUL-terminated tool-name pointer against a Zig string
59+
/// literal. Caller must have already verified `tool_name` is non-null
60+
/// (usually via `invokeArgsNull`).
61+
pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool {
62+
const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name)));
63+
return std.mem.eql(u8, s, expected);
64+
}
65+
66+
/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update
67+
/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`.
68+
///
69+
/// If `body.len` exceeds the current capacity stored in `*in_out_len`,
70+
/// sets `*in_out_len` to the required size and returns
71+
/// `RC_BUFFER_TOO_SMALL` — the caller is then expected to re-allocate
72+
/// and retry, per ADR-0006 §Memory ownership.
73+
///
74+
/// Caller must have already verified that `out_buf` and `in_out_len`
75+
/// are non-null.
76+
pub fn writeResult(
77+
out_buf: [*c]u8,
78+
in_out_len: [*c]usize,
79+
body: []const u8,
80+
) i32 {
81+
const cap = in_out_len.*;
82+
if (body.len > cap) {
83+
in_out_len.* = body.len;
84+
return RC_BUFFER_TOO_SMALL;
85+
}
86+
@memcpy(out_buf[0..body.len], body);
87+
in_out_len.* = body.len;
88+
return RC_SUCCESS;
89+
}
90+
91+
// ── Tests ────────────────────────────────────────────────────────────
92+
93+
test "writeResult: body fits, writes and sets length" {
94+
var buf: [64]u8 = undefined;
95+
var len: usize = buf.len;
96+
const rc = writeResult(&buf, &len, "hello");
97+
try std.testing.expectEqual(RC_SUCCESS, rc);
98+
try std.testing.expectEqual(@as(usize, 5), len);
99+
try std.testing.expectEqualStrings("hello", buf[0..len]);
100+
}
101+
102+
test "writeResult: too small returns -3 and sets required length" {
103+
var buf: [2]u8 = undefined;
104+
var len: usize = buf.len;
105+
const rc = writeResult(&buf, &len, "hello");
106+
try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc);
107+
try std.testing.expectEqual(@as(usize, 5), len);
108+
}
109+
110+
test "writeResult: exact-fit succeeds" {
111+
var buf: [5]u8 = undefined;
112+
var len: usize = buf.len;
113+
const rc = writeResult(&buf, &len, "hello");
114+
try std.testing.expectEqual(RC_SUCCESS, rc);
115+
try std.testing.expectEqual(@as(usize, 5), len);
116+
}
117+
118+
test "writeResult: empty body" {
119+
var buf: [4]u8 = undefined;
120+
var len: usize = buf.len;
121+
const rc = writeResult(&buf, &len, "");
122+
try std.testing.expectEqual(RC_SUCCESS, rc);
123+
try std.testing.expectEqual(@as(usize, 0), len);
124+
}
125+
126+
test "toolIs: matches and rejects" {
127+
const name: [*:0]const u8 = "foo";
128+
try std.testing.expect(toolIs(@ptrCast(name), "foo"));
129+
try std.testing.expect(!toolIs(@ptrCast(name), "bar"));
130+
try std.testing.expect(!toolIs(@ptrCast(name), "foobar"));
131+
try std.testing.expect(!toolIs(@ptrCast(name), "fo"));
132+
}
133+
134+
test "invokeArgsNull: detects each null slot" {
135+
var buf: [4]u8 = undefined;
136+
var len: usize = 4;
137+
const name: [*:0]const u8 = "x";
138+
try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len));
139+
try std.testing.expect(invokeArgsNull(null, &buf, &len));
140+
try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len));
141+
try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null));
142+
}

0 commit comments

Comments
 (0)