pi-opencode-cli-bridge 0.2.0 → 0.2.1
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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/index.ts +63 -1
package/README.md
CHANGED
|
@@ -116,7 +116,7 @@ Without an override, discovery selects active models whose reported input and ou
|
|
|
116
116
|
3. It creates a temporary OpenCode project containing a `pi-model` agent and a `tool.execute.before` plugin. Native tool declarations remain on the request. Execution is stopped twice: the plugin throws before any native tool runs, and the agent sets every permission to `ask`, which non-interactive `opencode run` rejects automatically. The second layer still holds if the plugin is missing or fails to load.
|
|
117
117
|
4. It sends the prompt over standard input to `opencode run --format json`. User and tool-result images are written to temporary files and attached only when the model supports images.
|
|
118
118
|
5. If the model calls a native OpenCode tool anyway, the call is still blocked. When every native call in that step has a Pi equivalent in the current tool list (`bash`, `read`, `write`, `edit` with a single replacement, `grep`, and `glob` as `find`), the bridge stops OpenCode and returns them as Pi tool calls. Paths inside the temporary project become relative to Pi's working directory. Otherwise OpenCode continues: the model sees a correction asking it to use a Pi tool, and the turn fails only if it does not recover.
|
|
119
|
-
6. Text markers such as `<pi_tool_call>{"name":"read","arguments":{"path":"example.txt"}}</pi_tool_call>` become Pi tool calls. Tool names must be in the current Pi tool list; execution then follows Pi's normal validation and permission pipeline.
|
|
119
|
+
6. Text markers such as `<pi_tool_call>{"name":"read","arguments":{"path":"example.txt"}}</pi_tool_call>` become Pi tool calls. Tool names must be in the current Pi tool list; execution then follows Pi's normal validation and permission pipeline. DeepSeek models sometimes write their native DSML tool-call blocks (`<|DSML|invoke name="...">`) as text instead; complete blocks are converted the same way, and an incomplete one fails the turn with a diagnostic instead of ending it silently.
|
|
120
120
|
7. Successful continuation turns reuse an OpenCode session and send only the new transcript. Changes to the model, prompt, tools or transcript prefix start a fresh session.
|
|
121
121
|
|
|
122
122
|
Pi's provider instrumentation hooks follow the CLI transport, not OpenCode's internal HTTP traffic:
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -100,6 +100,7 @@ export type ToolCallParseResult =
|
|
|
100
100
|
rejection:
|
|
101
101
|
| { reason: "malformed_markers" }
|
|
102
102
|
| { reason: "invalid_payload" }
|
|
103
|
+
| { reason: "incomplete_dsml" }
|
|
103
104
|
| { reason: "unavailable_tool"; toolName: string };
|
|
104
105
|
};
|
|
105
106
|
|
|
@@ -948,11 +949,70 @@ function stripMarkerSpans(text: string, spans: MarkerSpan[]): string {
|
|
|
948
949
|
return cleaned;
|
|
949
950
|
}
|
|
950
951
|
|
|
952
|
+
// DeepSeek sometimes ignores the markers and writes its native DSML tool-call
|
|
953
|
+
// syntax as plain text. Each complete block is rewritten in place as markers,
|
|
954
|
+
// so call order and the tool-list check stay those of the marker parser.
|
|
955
|
+
const DSML = String.raw`[||]+\s*DSML\s*[||]+`;
|
|
956
|
+
const DSML_TAG = new RegExp(String.raw`<\/?${DSML}`);
|
|
957
|
+
const DSML_BLOCK = new RegExp(String.raw`^<${DSML}(?:tool|function)_calls>([\s\S]*?)<\/${DSML}(?:tool|function)_calls>`);
|
|
958
|
+
const DSML_INVOKE = new RegExp(String.raw`<${DSML}invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/${DSML}invoke>`, "g");
|
|
959
|
+
const DSML_PARAMETER = new RegExp(String.raw`<${DSML}parameter\s+name="([^"]+)"(?:\s+string="(true|false)")?\s*>([\s\S]*?)<\/${DSML}parameter>`, "g");
|
|
960
|
+
|
|
961
|
+
function dsmlBlockToMarkers(body: string): string {
|
|
962
|
+
let markers = "";
|
|
963
|
+
for (const [, name, params] of body.matchAll(DSML_INVOKE)) {
|
|
964
|
+
const args: Record<string, unknown> = {};
|
|
965
|
+
for (const [, key, isString, raw] of params.matchAll(DSML_PARAMETER)) {
|
|
966
|
+
if (isString === "true") {
|
|
967
|
+
args[key] = raw;
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
try {
|
|
971
|
+
args[key] = JSON.parse(raw);
|
|
972
|
+
} catch {
|
|
973
|
+
args[key] = raw;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
markers += `${MARKER_OPEN}${JSON.stringify({ name, arguments: args })}</pi_tool_call>`;
|
|
977
|
+
}
|
|
978
|
+
return markers;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Scans left to right and consumes whichever starts first, a marker or a DSML
|
|
982
|
+
// block, so each keeps the other's syntax intact inside its own arguments.
|
|
983
|
+
// Returns undefined for a truncated DSML block, which would otherwise end the
|
|
984
|
+
// turn silently or let Pi run only the calls before it.
|
|
985
|
+
function dsmlToMarkers(text: string): string | undefined {
|
|
986
|
+
let result = "";
|
|
987
|
+
let rest = text;
|
|
988
|
+
for (;;) {
|
|
989
|
+
const dsmlAt = rest.search(DSML_TAG);
|
|
990
|
+
const markerAt = rest.indexOf(MARKER_OPEN);
|
|
991
|
+
if (dsmlAt < 0) return result + rest;
|
|
992
|
+
if (markerAt >= 0 && markerAt < dsmlAt) {
|
|
993
|
+
// The slice starts at the marker, so trimming cannot shift its span.
|
|
994
|
+
const extraction = toolCallMarkers(rest.slice(markerAt));
|
|
995
|
+
const span = [...(extraction?.markers ?? []), ...(extraction?.invalidSpans ?? [])].find((s) => s.start === 0);
|
|
996
|
+
const end = markerAt + (span?.end ?? MARKER_OPEN.length);
|
|
997
|
+
result += rest.slice(0, end);
|
|
998
|
+
rest = rest.slice(end);
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
const block = DSML_BLOCK.exec(rest.slice(dsmlAt));
|
|
1002
|
+
if (!block) return undefined;
|
|
1003
|
+
result += rest.slice(0, dsmlAt) + dsmlBlockToMarkers(block[1]);
|
|
1004
|
+
rest = rest.slice(dsmlAt + block[0].length);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
951
1008
|
export function parseToolCallResponse(
|
|
952
1009
|
text: string,
|
|
953
1010
|
allowedToolNames?: ReadonlySet<string>,
|
|
954
1011
|
): ToolCallParseResult {
|
|
955
|
-
const normalizedText = normalizeQuotedToolCallResponse(text);
|
|
1012
|
+
const normalizedText = dsmlToMarkers(normalizeQuotedToolCallResponse(text));
|
|
1013
|
+
if (normalizedText === undefined) {
|
|
1014
|
+
return { ok: false, rejection: { reason: "incomplete_dsml" } };
|
|
1015
|
+
}
|
|
956
1016
|
if (!isToolCallMarkerResponse(normalizedText)) return { ok: true, calls: [] };
|
|
957
1017
|
|
|
958
1018
|
const extraction = toolCallMarkers(normalizedText);
|
|
@@ -1032,6 +1092,8 @@ function toolCallRejectionMessage(
|
|
|
1032
1092
|
return "OpenCode returned malformed Pi tool-call markers. Ensure each marker contains valid JSON with a string \"name\" and object \"arguments\".";
|
|
1033
1093
|
case "invalid_payload":
|
|
1034
1094
|
return 'OpenCode returned an invalid Pi tool-call payload. Each marker must contain valid JSON with a non-empty string "name" and object "arguments".';
|
|
1095
|
+
case "incomplete_dsml":
|
|
1096
|
+
return 'OpenCode returned an incomplete DeepSeek DSML tool call. Pi tools must be requested with <pi_tool_call>{"name":"...","arguments":{}}</pi_tool_call> markers.';
|
|
1035
1097
|
case "unavailable_tool": {
|
|
1036
1098
|
const available = [...allowedToolNames].map((name) =>
|
|
1037
1099
|
JSON.stringify(name),
|