Skip to content

Commit 010569f

Browse files
authored
Merge pull request DeusData#1678 from musichen/fix/pi-extension-tool-schemas
fix(pi): emit parameters and execute in the generated pi adapter
2 parents 65c7cf1 + 104c9a7 commit 010569f

4 files changed

Lines changed: 144 additions & 6 deletions

File tree

‎src/cli/client_adapter.c‎

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,39 @@ static void sb_append(adapter_sb_t *sb, const char *s) {
4949
sb->buf[sb->len] = '\0';
5050
}
5151

52+
/* Append a single-quoted JavaScript string literal, escaping the characters
53+
* that could terminate or corrupt it. Avoids the fixed buffer sizing that
54+
* cbm_client_adapter_escape_js imposes, so long registry descriptions fit. */
55+
static void sb_append_js_string(adapter_sb_t *sb, const char *s) {
56+
if (!s) {
57+
sb_append(sb, "''");
58+
return;
59+
}
60+
sb_append(sb, "'");
61+
for (const char *p = s; *p; p++) {
62+
switch (*p) {
63+
case '\\':
64+
sb_append(sb, "\\\\");
65+
break;
66+
case '\'':
67+
sb_append(sb, "\\'");
68+
break;
69+
case '\n':
70+
sb_append(sb, "\\n");
71+
break;
72+
case '\r':
73+
sb_append(sb, "\\r");
74+
break;
75+
default: {
76+
char ch[2] = {*p, '\0'};
77+
sb_append(sb, ch);
78+
break;
79+
}
80+
}
81+
}
82+
sb_append(sb, "'");
83+
}
84+
5285
bool cbm_client_adapter_escape_js(const char *in, char *out, size_t out_sz) {
5386
if (!in || !out || out_sz == 0) {
5487
return false;
@@ -120,6 +153,10 @@ char *cbm_client_adapter_pi(const char *binary_path) {
120153

121154
adapter_sb_t sb = {0};
122155
emit_header(&sb, "pi");
156+
/* Pin the coding-agent contract so a probe against @mariozechner/pi
157+
* (pods CLI) or an old AgentTool arity cannot be mistaken for this file. */
158+
sb_append(&sb, "// Target: @earendil-works/pi-coding-agent >= 0.74.0 (verified 0.84.2)\n"
159+
"// ToolDefinition.execute(toolCallId, params, signal, onUpdate, ctx)\n");
123160
sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
124161
sb_append(&sb, "const BIN = '");
125162
sb_append(&sb, bin);
@@ -131,7 +168,7 @@ char *cbm_client_adapter_pi(const char *binary_path) {
131168
sb_append(
132169
&sb, "async function call(tool, args, signal) {\n"
133170
" return new Promise((resolve) => {\n"
134-
" const child = spawn(BIN, ['cli', tool, JSON.stringify(args ?? {})], {\n"
171+
" const child = spawn(BIN, ['cli', '--json', tool, JSON.stringify(args ?? {})], {\n"
135172
" stdio: ['ignore', 'pipe', 'pipe'],\n"
136173
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
137174
" });\n"
@@ -170,11 +207,37 @@ char *cbm_client_adapter_pi(const char *binary_path) {
170207
if (!name || !name[0]) {
171208
continue;
172209
}
173-
sb_append(&sb, " pi.registerTool({ name: '");
174-
sb_append(&sb, name);
175-
sb_append(&sb, "', run: (args, ctx) => call('");
176-
sb_append(&sb, name);
177-
sb_append(&sb, "', args, ctx?.signal) });\n");
210+
const char *title = cbm_mcp_tool_title(name);
211+
const char *description = cbm_mcp_tool_description(name);
212+
const char *schema = cbm_mcp_tool_input_schema(name);
213+
sb_append(&sb, " pi.registerTool({\n");
214+
sb_append(&sb, " name: ");
215+
sb_append_js_string(&sb, name);
216+
sb_append(&sb, ",\n label: ");
217+
sb_append_js_string(&sb, title ? title : name);
218+
sb_append(&sb, ",\n description: ");
219+
sb_append_js_string(&sb, description ? description : "");
220+
sb_append(&sb, ",\n parameters: ");
221+
/* input_schema is compact JSON, which is a valid JavaScript object
222+
* literal; embedding it directly keeps the generated module free of a
223+
* JSON.parse indirection and of any escaping drift. */
224+
sb_append(&sb, schema ? schema : "{}");
225+
/* 0.84.2 calls execute(toolCallId, params, signal, onUpdate, ctx).
226+
* The previous (args, ctx) shape bound the call id as the MCP args. */
227+
sb_append(&sb, ",\n execute: async (toolCallId, params, signal, _onUpdate, ctx) => {\n");
228+
sb_append(&sb, " const result = await call(");
229+
sb_append_js_string(&sb, name);
230+
sb_append(&sb,
231+
", params, signal ?? ctx?.signal);\n"
232+
" if (result && typeof result === 'object' && result.error) {\n"
233+
" throw new Error(String(result.error));\n"
234+
" }\n"
235+
" const content = result && Array.isArray(result.content)\n"
236+
" ? result.content\n"
237+
" : [{ type: 'text', text: JSON.stringify(result ?? null, null, 2) }];\n"
238+
" return { content, details: result ?? {} };\n"
239+
" },\n"
240+
" });\n");
178241
}
179242
sb_append(&sb, "}\n");
180243

‎src/mcp/mcp.c‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,30 @@ const char *cbm_mcp_tool_name(int index) {
946946
return TOOLS[index].name;
947947
}
948948

949+
const char *cbm_mcp_tool_title(const char *tool_name) {
950+
if (!tool_name) {
951+
return NULL;
952+
}
953+
for (int i = 0; i < TOOL_COUNT; i++) {
954+
if (strcmp(TOOLS[i].name, tool_name) == 0) {
955+
return TOOLS[i].title;
956+
}
957+
}
958+
return NULL;
959+
}
960+
961+
const char *cbm_mcp_tool_description(const char *tool_name) {
962+
if (!tool_name) {
963+
return NULL;
964+
}
965+
for (int i = 0; i < TOOL_COUNT; i++) {
966+
if (strcmp(TOOLS[i].name, tool_name) == 0) {
967+
return TOOLS[i].description;
968+
}
969+
}
970+
return NULL;
971+
}
972+
949973
/* Render the top-level --help "Tools:" block from the registry tools/list
950974
* serves. The list used to be hand-maintained in the help text and drifted
951975
* when check_index_coverage was added (#1361); deriving it here makes that

‎src/mcp/mcp.h‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ const char *cbm_mcp_tool_input_schema(const char *tool_name);
7272
int cbm_mcp_tool_count(void);
7373
const char *cbm_mcp_tool_name(int index);
7474

75+
/* Tool metadata by name (static; do not free; NULL when unknown). Used by the
76+
* generated client adapters so they can emit descriptions and parameter
77+
* schemas without drifting from tools/list. */
78+
const char *cbm_mcp_tool_title(const char *tool_name);
79+
const char *cbm_mcp_tool_description(const char *tool_name);
80+
7581
/* Render the top-level --help "Tools:" block from the registry so the help
7682
* text cannot drift from tools/list (#1361). Heap-allocated; caller frees. */
7783
char *cbm_mcp_tools_help_list(void);

‎tests/test_agent_clients.c‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,49 @@ TEST(client_adapter_pi_default_exports_its_factory_issue1550) {
11141114
PASS();
11151115
}
11161116

1117+
/* Pi's ToolDefinition requires `parameters` and `execute`. The adapter used to
1118+
* emit `{ name, run }`, which Pi accepted but treated as a tool with no
1119+
* parameter schema — strict providers (xAI/Grok) then reject the request with
1120+
* a 422 "missing field parameters", and the tool is uncallable because Pi
1121+
* invokes `execute`, never `run`. Pin the corrected shape. */
1122+
TEST(client_adapter_pi_emits_parameters_and_execute) {
1123+
char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp");
1124+
ASSERT_NOT_NULL(js);
1125+
ASSERT_NOT_NULL(strstr(js, "execute: async (toolCallId, params, signal, _onUpdate, ctx) => {"));
1126+
ASSERT_NOT_NULL(strstr(js, ", params, signal ?? ctx?.signal)"));
1127+
ASSERT_NULL(strstr(js, "execute: async (args, ctx) => {"));
1128+
ASSERT_NULL(strstr(js, ", args, ctx?.signal)"));
1129+
ASSERT_NOT_NULL(strstr(js, "@earendil-works/pi-coding-agent"));
1130+
ASSERT_NOT_NULL(strstr(js, "result.content"));
1131+
ASSERT_NULL(strstr(js, "run: (args, ctx)"));
1132+
ASSERT_NOT_NULL(strstr(js, "parameters:"));
1133+
/* The registry input_schema is embedded as a JSON object literal. */
1134+
ASSERT_NOT_NULL(strstr(js, "\"type\":\"object\""));
1135+
/* Raw JSON output is required so the bridge can parse the MCP result; the
1136+
* human-readable path would leave `call` with nothing to JSON.parse. */
1137+
ASSERT_NOT_NULL(strstr(js, "'cli', '--json'"));
1138+
free(js);
1139+
PASS();
1140+
}
1141+
1142+
/* Result wrap + abort: Pi's TUI reads result.content; a spawn/parse failure
1143+
* must throw rather than return a result without content; AbortSignal must
1144+
* kill the cli child. String-shape only; a live Pi process is not gated. */
1145+
TEST(client_adapter_pi_wraps_result_and_honors_abort) {
1146+
char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp");
1147+
ASSERT_NOT_NULL(js);
1148+
ASSERT_NOT_NULL(strstr(js, "return { content, details: result ?? {} };"));
1149+
ASSERT_NOT_NULL(strstr(js, "if (result && typeof result === 'object' && result.error)"));
1150+
ASSERT_NOT_NULL(strstr(js, "throw new Error(String(result.error));"));
1151+
ASSERT_NOT_NULL(strstr(js, "Array.isArray(result.content)"));
1152+
ASSERT_NOT_NULL(
1153+
strstr(js, "[{ type: 'text', text: JSON.stringify(result ?? null, null, 2) }]"));
1154+
ASSERT_NOT_NULL(strstr(js, "signal?.addEventListener('abort', onAbort, { once: true })"));
1155+
ASSERT_NOT_NULL(strstr(js, "if (!child.killed) child.kill();"));
1156+
free(js);
1157+
PASS();
1158+
}
1159+
11171160
/* #616 rejected `"` in its path template but not `\`, so a Windows home like
11181161
* C:\Users\urs\bin produced `\u` — an invalid unicode escape — and the whole
11191162
* auto-loaded plugin failed to parse. A broken plugin in an auto-load directory
@@ -1204,6 +1247,8 @@ SUITE(agent_clients) {
12041247
RUN_TEST(agent_clients_continue_refuses_foreign_same_name_and_nonsequence_section);
12051248
RUN_TEST(client_adapter_pi_registers_every_registry_tool);
12061249
RUN_TEST(client_adapter_pi_default_exports_its_factory_issue1550);
1250+
RUN_TEST(client_adapter_pi_emits_parameters_and_execute);
1251+
RUN_TEST(client_adapter_pi_wraps_result_and_honors_abort);
12071252
RUN_TEST(client_adapter_escapes_windows_paths_and_quotes);
12081253
RUN_TEST(client_adapter_opencode_sends_the_required_hook_event);
12091254
RUN_TEST(client_adapter_rejects_missing_binary_path);

0 commit comments

Comments
 (0)